Add a proxy pool with rotation, liveness checks and a management page

The parser will need to move between proxies, so this adds the module it will
sit on: pluggable sources, a pool that hands proxies out and learns from the
outcome, three rotation strategies, and a page to drive it.

Sources are IProxySource implementations. The public proxifly/free-proxy-list
feed is fetched as the combined all/data.json through jsDelivr and filtered
locally — one conditional request beats four per-protocol ones that can disagree
mid-publish — and cached for the five minutes upstream takes to regenerate. A
feed that is down keeps serving its last payload rather than emptying the pool.
The user's own list lives in proxies.custom.json beside the settings, takes a
pasted blob, and names the lines it could not parse instead of quietly dropping
them.

Both knobs the pool exposes are settings, as asked: rotation is Sticky (default,
the only one that keeps site sessions coherent), RoundRobin or WeightedRandom;
liveness is either a parallel sweep of the whole pool or a probe at hand-out
time. Free lists are a few percent alive, so skipping verification entirely
means mostly waiting on timeouts.

Two invariants worth keeping, both of which cost a bug to find:

Availability is decided by the quarantine, never by Health. Excluding everything
that has ever failed made the quarantine window dead code and discarded proxies
permanently on their first hiccup, which is exactly wrong for addresses that
flap constantly. Health only orders the candidates now.

A probe verdict does not touch the success/failure counters. Those are about
real requests, and letting a sweep over a few thousand proxies rewrite them
would drown the evidence weighted selection reads.

SOCKS needs no extra package — .NET resolves socks4/socks4a/socks5 in WebProxy —
but a proxifly record with "protocol": "https" is still an HTTP proxy reached
over http:// with CONNECT, not an https:// scheme.

115 new tests. Also fixes a pre-existing flake: a command gated on another
command's IsExecuting cannot be driven straight after its Execute() completes,
because IsExecuting is published on the output scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 17:22:30 +03:00
co-authored by Claude Opus 5
parent aeafe0af36
commit 9bf2ea5532
48 changed files with 4422 additions and 5 deletions
+1
View File
@@ -23,6 +23,7 @@
<Folder Name="/tests/"> <Folder Name="/tests/">
<File Path="tests/Directory.Build.props" /> <File Path="tests/Directory.Build.props" />
<Project Path="tests/AvParser.Core.Tests/AvParser.Core.Tests.csproj" /> <Project Path="tests/AvParser.Core.Tests/AvParser.Core.Tests.csproj" />
<Project Path="tests/AvParser.Infrastructure.Tests/AvParser.Infrastructure.Tests.csproj" />
<Project Path="tests/AvParser.UI.Tests/AvParser.UI.Tests.csproj" /> <Project Path="tests/AvParser.UI.Tests/AvParser.UI.Tests.csproj" />
<Project Path="tests/AvParser.UI.HeadlessTests/AvParser.UI.HeadlessTests.csproj" /> <Project Path="tests/AvParser.UI.HeadlessTests/AvParser.UI.HeadlessTests.csproj" />
</Folder> </Folder>
+28
View File
@@ -84,6 +84,29 @@ dotnet csharpier check .
выбор конструктора контейнером зависит от порядка регистраций. выбор конструктора контейнером зависит от порядка регистраций.
- `[Reactive]` из `ReactiveUI.SourceGenerators` на partial-свойствах; класс — `partial`. - `[Reactive]` из `ReactiveUI.SourceGenerators` на partial-свойствах; класс — `partial`.
## Добавить источник прокси
1. Реализовать `IProxySource` (или `IMutableProxySource`, если список редактируемый).
2. Зарегистрировать как `IProxySource` в `AddAvParserProxies()`. Порядок регистрации = порядок
слияния; свой список идёт последним, чтобы пользовательский адрес перебивал фидовый.
`ProxyPool` при обновлении **переиспользует существующие `ProxyEntry`** по `Endpoint.Key` — иначе
перезагрузка списка стирала бы всю накопленную статистику, а публичные фиды переиздаются каждые
несколько минут.
## Инварианты прокси-пула
- **Доступность определяется карантином, а не `Health`.** `Health` — это «что видели в последний
раз». Если исключать всё, что когда-либо падало, окно карантина становится бессмысленным, а
прокси теряется навсегда после первой же осечки. Это уже был баг, его ловит
`A_failing_proxy_is_quarantined_and_comes_back_later`.
- **Проба не трогает `SuccessCount`/`FailureCount`.** Эти счётчики про реальные запросы; свип по
паре тысяч прокси перезаписал бы всё, на чём держится взвешенный выбор. Провалившаяся проба
выставляет карантин через `RecordProbe(..., quarantineOnFailure:)`.
- **Лиза без вердикта нейтральна.** Отменённая операция — не вина прокси; считать это отказом
значит карантинить здоровые прокси на каждый Cancel.
- **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`.
## Грабли, уже оплаченные ## Грабли, уже оплаченные
- **Селектор типа в Avalonia матчит точный тип.** `UserControl.shell` не матчит `ShellView` - **Селектор типа в Avalonia матчит точный тип.** `UserControl.shell` не матчит `ShellView`
@@ -110,6 +133,11 @@ dotnet csharpier check .
резолвиться, даже если эту конфигурацию никто не собирает. Так тут проехал мёртвый резолвиться, даже если эту конфигурацию никто не собирает. Так тут проехал мёртвый
`Avalonia.Diagnostics` (его нет под Avalonia 12): `dotnet build -c Release` работал, `Avalonia.Diagnostics` (его нет под Avalonia 12): `dotnet build -c Release` работал,
а голый `dotnet restore` падал. а голый `dotnet restore` падал.
- **`Execute()` завершился ≠ `IsExecuting` уже false.** Второе публикуется на выходном
планировщике. Тест, который сразу после `await` дёргает команду, закрытую по чужому
`IsExecuting`, будет мигать под нагрузкой — ждите `CanExecute`, а не предполагайте.
- **Проект VM-тестов не параллелится.** `ReactiveUiBootstrap` ставит глобальные планировщики
ReactiveUI, то есть тесты делят изменяемое состояние независимо от их желания.
- **Инспектора в Avalonia 12 нет из коробки.** `Avalonia.Diagnostics` закончился на 11.3.x; - **Инспектора в Avalonia 12 нет из коробки.** `Avalonia.Diagnostics` закончился на 11.3.x;
DevTools живут отдельно (`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`), со своей DevTools живут отдельно (`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`), со своей
установкой. Зависимость намеренно не добавлена. установкой. Зависимость намеренно не добавлена.
+1
View File
@@ -41,6 +41,7 @@
Include="Microsoft.Extensions.DependencyInjection.Abstractions" Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="$(MicrosoftExtensionsVersion)" Version="$(MicrosoftExtensionsVersion)"
/> />
<PackageVersion Include="Microsoft.Extensions.Http" Version="$(MicrosoftExtensionsVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="$(MicrosoftExtensionsVersion)" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="$(MicrosoftExtensionsVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftExtensionsVersion)" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftExtensionsVersion)" />
</ItemGroup> </ItemGroup>
+53 -3
View File
@@ -61,9 +61,10 @@ src/
ResponsiveLayout, дизайн-токены, навигация ResponsiveLayout, дизайн-токены, навигация
AvParser.Desktop WinExe-хост: Program.cs, App.axaml, composition root AvParser.Desktop WinExe-хост: Program.cs, App.axaml, composition root
tests/ tests/
AvParser.Core.Tests парсеры, реестр, отмена, прогресс AvParser.Core.Tests парсеры, реестр, отмена, прогресс, пул прокси и стратегии
AvParser.UI.Tests ViewModel'и без Avalonia AvParser.Infrastructure.Tests разбор фида прокси, локальный список, маппинг на WebProxy
AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact] AvParser.UI.Tests ViewModel'и без Avalonia
AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact]
``` ```
Ссылки идут строго в одну сторону: `Core ← Infrastructure ← UI ← Desktop`. Ссылки идут строго в одну сторону: `Core ← Infrastructure ← UI ← Desktop`.
@@ -102,6 +103,55 @@ Avalonia матчит **точный** тип, а `ShellView` наследует
--- ---
## Прокси
Пул прокси с ротацией — `AvParser.Core/Proxies`, источники и сетевая часть —
`AvParser.Infrastructure/Proxies`, управление — страница **Proxies**.
Источники:
- **[proxifly/free-proxy-list](https://github.com/proxifly/free-proxy-list)** — публичный список,
обновляется каждые 5 минут. Тянем сводный `all/data.json` через jsDelivr и фильтруем локально:
один условный запрос за весь список надёжнее четырёх по протоколам, которые могут разъехаться
между собой в момент публикации. Ответ кэшируется на 5 минут, недоступность фида не роняет
приложение — остаётся прошлый список.
- **Свой список** — `proxies.custom.json` рядом с настройками. Вставляется пачкой, по одной на
строку; поддерживаются `scheme://host:port`, голый `host:port` и `user:pass@`. Непонятые строки
не проглатываются молча, а называются в статусе.
Ротация выбирается в настройках:
| Стратегия | Поведение | Когда |
|---|---|---|
| Sticky | одна прокси, смена только по отказу | по умолчанию: не рвёт сессии и cookie |
| RoundRobin | новая на каждый запрос | размазывает рейт-лимиты, но ломает сессии |
| WeightedRandom | случайно, с весом по score и доле успехов | при сильном разбросе качества |
Проверка живости — тоже настройка, два режима: **Pool** прогоняет весь список параллельно один
раз, **Lazy** проверяет прокси в момент выдачи и перескакивает на следующую. У бесплатных списков
рабочих обычно единицы процентов, поэтому без проверки парсер будет в основном ждать таймауты.
Упавшая прокси уходит в карантин с экспоненциальным окном (30 с → 15 мин), но **не** удаляется
навсегда: бесплатные прокси постоянно мигают, и жёсткий бан терял бы их безвозвратно.
Использование из кода:
```csharp
var (http, lease) = await clientFactory.CreateFromPoolAsync();
using (http)
using (lease)
{
try { var response = await http.GetAsync(url); lease?.ReportSuccess(); }
catch { lease?.ReportFailure("request failed"); throw; }
}
```
Отчёт об исходе — не формальность: без него пул ничего не узнаёт о том, какие прокси работают.
Освобождение лизы без вердикта нейтрально — отменённая операция не вина прокси.
SOCKS работает штатно: .NET понимает схемы `socks4/socks4a/socks5` в `WebProxy`. Учтите, что
proxifly-запись с `"protocol": "https"` — это всё равно HTTP-прокси с CONNECT, а не схема `https://`.
## Дизайн-токены ## Дизайн-токены
Все цвета, отступы, радиусы и типографика — в `Styles/Tokens.axaml`, с отдельными словарями Все цвета, отступы, радиусы и типографика — в `Styles/Tokens.axaml`, с отдельными словарями
+89
View File
@@ -0,0 +1,89 @@
namespace AvParser.Core.Proxies;
/// <summary>The pool of known proxies and the thing that hands them out.</summary>
public interface IProxyPool
{
/// <summary>Snapshot of every known proxy, feed and custom alike.</summary>
IReadOnlyList<ProxyEntry> Entries { get; }
/// <summary>Options currently in force.</summary>
ProxyOptions Options { get; }
/// <summary>Raised after the set of entries or their health changes.</summary>
/// <remarks>
/// A plain event rather than <c>IObservable</c> so the domain keeps no reactive dependency;
/// the UI layer bridges it to an observable where that is convenient.
/// </remarks>
event EventHandler? Changed;
/// <summary>Applies new options. Resets selection state when the rotation strategy changes.</summary>
void Configure(ProxyOptions options);
/// <summary>Reloads from every source, preserving health statistics for addresses that survive.</summary>
/// <returns>Number of entries in the pool afterwards.</returns>
Task<int> RefreshAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Takes a proxy out of the pool for one unit of work, or <see langword="null"/> when nothing
/// usable is left.
/// </summary>
/// <remarks>Report the outcome on the lease, otherwise the pool never learns anything.</remarks>
Task<ProxyLease?> AcquireAsync(CancellationToken cancellationToken = default);
/// <summary>Probes every entry in parallel and updates their health.</summary>
/// <returns>How many answered.</returns>
Task<int> SweepAsync(IProgress<ProxySweepProgress>? progress = null, CancellationToken cancellationToken = default);
}
/// <summary>
/// A proxy checked out of the pool.
/// </summary>
/// <remarks>
/// Disposing without a verdict is deliberately neutral: an operation that was cancelled says
/// nothing about the proxy, and counting that as a failure would quarantine healthy entries
/// every time the user hits Cancel.
/// </remarks>
public sealed class ProxyLease : IDisposable
{
private readonly ProxyPool _pool;
private bool _reported;
internal ProxyLease(ProxyPool pool, ProxyEntry entry)
{
_pool = pool;
Entry = entry;
}
/// <summary>The pool entry backing this lease.</summary>
public ProxyEntry Entry { get; }
/// <summary>The address to send traffic through.</summary>
public ProxyEndpoint Endpoint => Entry.Endpoint;
/// <summary>Records that the work succeeded.</summary>
public void ReportSuccess(TimeSpan? latency = null)
{
if (_reported)
{
return;
}
_reported = true;
_pool.ReportOutcome(Entry, success: true, latency, error: null);
}
/// <summary>Records that the work failed, which may quarantine the proxy.</summary>
public void ReportFailure(string? error = null)
{
if (_reported)
{
return;
}
_reported = true;
_pool.ReportOutcome(Entry, success: false, latency: null, error);
}
/// <inheritdoc />
public void Dispose() => _reported = true;
}
+65
View File
@@ -0,0 +1,65 @@
namespace AvParser.Core.Proxies;
/// <summary>Supplies proxy addresses. One per list the app knows about.</summary>
public interface IProxySource
{
/// <summary>Stable identifier used in settings and logs.</summary>
string Id { get; }
/// <summary>Human-readable name for the UI.</summary>
string DisplayName { get; }
/// <summary>Whether entries from this source are feed-provided or user-entered.</summary>
ProxySourceKind Kind { get; }
/// <summary>Fetches the current list. Implementations may cache.</summary>
Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default);
}
/// <summary>A source the user can edit.</summary>
public interface IMutableProxySource : IProxySource
{
/// <summary>Adds addresses, ignoring duplicates. Returns how many were actually new.</summary>
Task<int> AddAsync(IEnumerable<ProxyEndpoint> endpoints, CancellationToken cancellationToken = default);
/// <summary>Removes an address. Returns whether it was present.</summary>
Task<bool> RemoveAsync(ProxyEndpoint endpoint, CancellationToken cancellationToken = default);
/// <summary>Removes every address.</summary>
Task ClearAsync(CancellationToken cancellationToken = default);
}
/// <summary>Outcome of a single liveness probe.</summary>
/// <param name="Alive">Whether the proxy answered acceptably.</param>
/// <param name="Latency">Round-trip time when alive.</param>
/// <param name="Error">Short failure reason when not alive.</param>
public readonly record struct ProxyProbeResult(bool Alive, TimeSpan? Latency, string? Error)
{
/// <summary>A successful probe.</summary>
public static ProxyProbeResult Success(TimeSpan latency) => new(true, latency, null);
/// <summary>A failed probe.</summary>
public static ProxyProbeResult Failure(string error) => new(false, null, error);
}
/// <summary>Checks whether a proxy actually works.</summary>
public interface IProxyProbe
{
/// <summary>Sends one request through <paramref name="endpoint"/> and reports the outcome.</summary>
/// <remarks>Must not throw for an unreachable proxy — that is a <see cref="ProxyProbeResult"/>, not an error.</remarks>
Task<ProxyProbeResult> ProbeAsync(
ProxyEndpoint endpoint,
ProxyOptions options,
CancellationToken cancellationToken = default
);
}
/// <summary>Progress of a pool-wide probe sweep.</summary>
/// <param name="Checked">Proxies probed so far.</param>
/// <param name="Total">Proxies in the sweep.</param>
/// <param name="Alive">How many answered.</param>
public readonly record struct ProxySweepProgress(int Checked, int Total, int Alive)
{
/// <summary>Completion in the range 0..1.</summary>
public double Fraction => Total <= 0 ? 0d : Math.Clamp((double)Checked / Total, 0d, 1d);
}
+210
View File
@@ -0,0 +1,210 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace AvParser.Core.Proxies;
/// <summary>Wire protocol a proxy speaks.</summary>
public enum ProxyProtocol
{
/// <summary>Plain HTTP proxy.</summary>
Http,
/// <summary>HTTP proxy that also handles CONNECT for TLS.</summary>
Https,
/// <summary>SOCKS4.</summary>
Socks4,
/// <summary>SOCKS5.</summary>
Socks5,
}
/// <summary>How much of the caller the proxy passes through.</summary>
public enum ProxyAnonymity
{
/// <summary>Not reported by the source.</summary>
Unknown,
/// <summary>Forwards the original address — offers no anonymity at all.</summary>
Transparent,
/// <summary>Hides the original address but announces itself as a proxy.</summary>
Anonymous,
/// <summary>Neither forwards the address nor announces itself.</summary>
Elite,
}
/// <summary>Where an entry came from.</summary>
public enum ProxySourceKind
{
/// <summary>Downloaded from a remote list.</summary>
Feed,
/// <summary>Entered by the user and stored locally.</summary>
Custom,
}
/// <summary>A single proxy address, with whatever metadata its source supplied.</summary>
/// <param name="Protocol">Wire protocol.</param>
/// <param name="Host">Hostname or IP literal.</param>
/// <param name="Port">TCP port.</param>
public sealed record ProxyEndpoint(ProxyProtocol Protocol, string Host, int Port)
{
/// <summary>ISO country code reported by the source, if any.</summary>
public string? Country { get; init; }
/// <summary>City reported by the source, if any.</summary>
public string? City { get; init; }
/// <summary>Anonymity level reported by the source.</summary>
public ProxyAnonymity Anonymity { get; init; } = ProxyAnonymity.Unknown;
/// <summary>Quality score reported by the source; higher is better. 0 when unknown.</summary>
public int Score { get; init; }
/// <summary>Username for proxies that need authentication.</summary>
public string? Username { get; init; }
/// <summary>Password for proxies that need authentication.</summary>
public string? Password { get; init; }
/// <summary>Scheme as <c>System.Net.WebProxy</c> expects it.</summary>
public string Scheme =>
Protocol switch
{
ProxyProtocol.Socks4 => "socks4",
ProxyProtocol.Socks5 => "socks5",
// .NET has no "https" proxy scheme: an HTTPS-capable proxy is still reached over
// http:// and tunnels TLS with CONNECT.
_ => "http",
};
/// <summary>Address in <c>scheme://host:port</c> form.</summary>
public Uri Uri => new($"{Scheme}://{Host}:{Port.ToString(CultureInfo.InvariantCulture)}");
/// <summary>Stable identity: two entries for the same address are the same proxy.</summary>
public string Key => $"{Protocol}://{Host}:{Port.ToString(CultureInfo.InvariantCulture)}".ToLowerInvariant();
/// <summary>Whether credentials were supplied.</summary>
public bool HasCredentials => !string.IsNullOrEmpty(Username);
/// <inheritdoc />
public override string ToString() =>
$"{Protocol.ToString().ToLowerInvariant()}://{Host}:{Port.ToString(CultureInfo.InvariantCulture)}";
/// <summary>
/// Parses <c>[scheme://][user:pass@]host:port</c>. Missing scheme is treated as HTTP.
/// </summary>
/// <remarks>
/// Hand-rolled rather than delegating to <see cref="Uri"/>: the socks schemes and the
/// bare <c>host:port</c> form that every proxy list uses are not valid absolute URIs.
/// </remarks>
public static bool TryParse(string? text, [NotNullWhen(true)] out ProxyEndpoint? endpoint)
{
endpoint = null;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var value = text.Trim();
var protocol = ProxyProtocol.Http;
var schemeEnd = value.IndexOf("://", StringComparison.Ordinal);
if (schemeEnd >= 0)
{
if (!TryParseProtocol(value[..schemeEnd], out protocol))
{
return false;
}
value = value[(schemeEnd + 3)..];
}
string? username = null;
string? password = null;
// Rightmost '@' wins: a password may legitimately contain one.
var credentialsEnd = value.LastIndexOf('@');
if (credentialsEnd >= 0)
{
var credentials = value[..credentialsEnd];
value = value[(credentialsEnd + 1)..];
var separator = credentials.IndexOf(':', StringComparison.Ordinal);
if (separator < 0)
{
username = credentials;
}
else
{
username = credentials[..separator];
password = credentials[(separator + 1)..];
}
if (username.Length == 0)
{
return false;
}
}
var portStart = value.LastIndexOf(':');
if (portStart <= 0 || portStart == value.Length - 1)
{
return false;
}
var host = value[..portStart].Trim();
var portText = value[(portStart + 1)..].Trim();
if (
host.Length == 0
|| !int.TryParse(portText, NumberStyles.None, CultureInfo.InvariantCulture, out var port)
|| port is < 1 or > 65535
)
{
return false;
}
endpoint = new ProxyEndpoint(protocol, host, port) { Username = username, Password = password };
return true;
}
/// <summary>Parses a protocol name; accepts the spellings the public lists use.</summary>
public static bool TryParseProtocol(string? text, out ProxyProtocol protocol)
{
switch (text?.Trim().ToLowerInvariant())
{
case "http":
protocol = ProxyProtocol.Http;
return true;
case "https":
case "ssl":
protocol = ProxyProtocol.Https;
return true;
case "socks4":
case "socks4a":
protocol = ProxyProtocol.Socks4;
return true;
case "socks5":
case "socks5h":
protocol = ProxyProtocol.Socks5;
return true;
default:
protocol = ProxyProtocol.Http;
return false;
}
}
/// <summary>Parses an anonymity level; unrecognised values become <see cref="ProxyAnonymity.Unknown"/>.</summary>
public static ProxyAnonymity ParseAnonymity(string? text) =>
text?.Trim().ToLowerInvariant() switch
{
"transparent" => ProxyAnonymity.Transparent,
"anonymous" => ProxyAnonymity.Anonymous,
"elite" or "high" => ProxyAnonymity.Elite,
_ => ProxyAnonymity.Unknown,
};
}
+186
View File
@@ -0,0 +1,186 @@
namespace AvParser.Core.Proxies;
/// <summary>What the last check or use said about a proxy.</summary>
public enum ProxyHealthState
{
/// <summary>Never checked and never used.</summary>
Unknown,
/// <summary>A probe or a real request succeeded.</summary>
Alive,
/// <summary>A probe or a real request failed.</summary>
Dead,
}
/// <summary>
/// A proxy plus everything the pool has learned about it.
/// </summary>
/// <remarks>
/// Mutable and guarded by <see cref="ProxyPool"/>'s lock rather than being a record: the pool
/// updates counters on every request, and reallocating an immutable entry per outcome would
/// churn hard on a list of a few thousand proxies.
/// </remarks>
public sealed class ProxyEntry
{
private readonly Lock _gate = new();
/// <summary>Creates an entry in the <see cref="ProxyHealthState.Unknown"/> state.</summary>
public ProxyEntry(ProxyEndpoint endpoint, ProxySourceKind source)
{
Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
Source = source;
}
/// <summary>The address.</summary>
public ProxyEndpoint Endpoint { get; }
/// <summary>Whether this came from a feed or from the user.</summary>
public ProxySourceKind Source { get; }
/// <summary>Latest known state.</summary>
public ProxyHealthState Health { get; private set; }
/// <summary>Round-trip time of the last successful probe or request.</summary>
public TimeSpan? Latency { get; private set; }
/// <summary>When the state was last updated.</summary>
public DateTimeOffset? LastCheckedUtc { get; private set; }
/// <summary>Successful uses since the entry was created.</summary>
public int SuccessCount { get; private set; }
/// <summary>Failed uses since the entry was created.</summary>
public int FailureCount { get; private set; }
/// <summary>Failures since the last success. Drives the quarantine backoff.</summary>
public int ConsecutiveFailures { get; private set; }
/// <summary>While set and in the future, the entry is skipped by selection.</summary>
public DateTimeOffset? QuarantinedUntilUtc { get; private set; }
/// <summary>Reason recorded with the last failure, for the UI.</summary>
public string? LastError { get; private set; }
/// <summary>Share of successful uses, 0..1. Returns 0.5 before any evidence exists.</summary>
public double SuccessRate
{
get
{
var total = SuccessCount + FailureCount;
return total == 0 ? 0.5d : (double)SuccessCount / total;
}
}
/// <summary>Whether selection may hand this entry out at <paramref name="now"/>.</summary>
/// <remarks>
/// Governed by the quarantine alone, deliberately not by <see cref="Health"/>. Health is the
/// last thing observed; excluding every entry that has ever failed would make the quarantine
/// window meaningless and would permanently discard proxies on their first hiccup — and free
/// proxies flap constantly. Health still decides ordering, so dead entries sink to the back.
/// </remarks>
public bool IsAvailable(DateTimeOffset now) => QuarantinedUntilUtc is null || QuarantinedUntilUtc <= now;
/// <summary>Whether the entry is currently serving a quarantine.</summary>
public bool IsQuarantined(DateTimeOffset now) => QuarantinedUntilUtc is { } until && until > now;
/// <summary>Records a successful probe or request.</summary>
public void RecordSuccess(DateTimeOffset now, TimeSpan? latency = null)
{
lock (_gate)
{
Health = ProxyHealthState.Alive;
SuccessCount++;
ConsecutiveFailures = 0;
QuarantinedUntilUtc = null;
LastError = null;
LastCheckedUtc = now;
if (latency is not null)
{
Latency = latency;
}
}
}
/// <summary>
/// Records a failure and, once <paramref name="failuresBeforeQuarantine"/> pile up, sidelines
/// the entry for an exponentially growing window capped at <paramref name="maxQuarantine"/>.
/// </summary>
/// <remarks>
/// Exponential rather than fixed: a proxy that fails once may just have hit a flaky moment,
/// while one that has failed six times in a row should not be retried every few seconds for
/// the rest of the session.
/// </remarks>
public void RecordFailure(
DateTimeOffset now,
TimeSpan baseQuarantine,
TimeSpan maxQuarantine,
int failuresBeforeQuarantine,
string? error = null
)
{
lock (_gate)
{
FailureCount++;
ConsecutiveFailures++;
LastCheckedUtc = now;
LastError = error;
Health = ProxyHealthState.Dead;
if (ConsecutiveFailures < failuresBeforeQuarantine)
{
return;
}
var exponent = Math.Min(ConsecutiveFailures - failuresBeforeQuarantine, 16);
var ticks = baseQuarantine.Ticks * Math.Pow(2, exponent);
var window = ticks >= maxQuarantine.Ticks ? maxQuarantine : TimeSpan.FromTicks((long)ticks);
QuarantinedUntilUtc = now + window;
}
}
/// <summary>Records the outcome of a liveness probe.</summary>
/// <param name="now">Current time.</param>
/// <param name="alive">Whether the probe succeeded.</param>
/// <param name="latency">Round-trip time when alive.</param>
/// <param name="error">Failure reason when not alive.</param>
/// <param name="quarantineOnFailure">
/// How long to sideline the entry if the probe failed. Leave null to only record the state.
/// </param>
/// <remarks>
/// Does not touch <see cref="SuccessCount"/> or <see cref="FailureCount"/>: those track real
/// requests, and letting a sweep of a few thousand proxies rewrite them would drown the
/// evidence that weighted selection depends on.
/// </remarks>
public void RecordProbe(
DateTimeOffset now,
bool alive,
TimeSpan? latency,
string? error,
TimeSpan? quarantineOnFailure = null
)
{
lock (_gate)
{
Health = alive ? ProxyHealthState.Alive : ProxyHealthState.Dead;
LastCheckedUtc = now;
Latency = alive ? latency : null;
LastError = alive ? null : error;
if (alive)
{
ConsecutiveFailures = 0;
QuarantinedUntilUtc = null;
}
else if (quarantineOnFailure is { } window)
{
QuarantinedUntilUtc = now + window;
}
}
}
/// <inheritdoc />
public override string ToString() => $"{Endpoint} [{Health}]";
}
+129
View File
@@ -0,0 +1,129 @@
namespace AvParser.Core.Proxies;
/// <summary>How the pool picks the next proxy.</summary>
public enum ProxyRotation
{
/// <summary>Keep one proxy until it fails. Least disruptive to session cookies.</summary>
Sticky,
/// <summary>Advance through the pool on every acquisition. Spreads rate limits.</summary>
RoundRobin,
/// <summary>Pick at random, weighted by feed score and observed success rate.</summary>
WeightedRandom,
}
/// <summary>When liveness is verified.</summary>
public enum ProxyHealthCheck
{
/// <summary>
/// Probe the whole pool up front, in parallel. Costs one sweep, then hands out proxies with
/// no extra latency — the right default when most of a free list is dead.
/// </summary>
Pool,
/// <summary>
/// Probe a single proxy at the moment it is handed out, skipping to the next if it fails.
/// No sweep, but every acquisition pays a round trip.
/// </summary>
Lazy,
}
/// <summary>Protocols to accept when loading sources.</summary>
/// <remarks>
/// A flags enum rather than a list so that <see cref="ProxyOptions"/> and the persisted settings
/// keep value equality — a record holding a collection compares by reference, which would make
/// every "did anything change?" check say yes.
/// </remarks>
[Flags]
public enum ProxyProtocolFilter
{
/// <summary>No filter — accept everything.</summary>
None = 0,
/// <summary>Plain HTTP proxies.</summary>
Http = 1,
/// <summary>HTTPS-capable HTTP proxies.</summary>
Https = 2,
/// <summary>SOCKS4.</summary>
Socks4 = 4,
/// <summary>SOCKS5.</summary>
Socks5 = 8,
/// <summary>Every protocol.</summary>
All = Http | Https | Socks4 | Socks5,
}
/// <summary>Tuning for <see cref="ProxyPool"/>. Mirrors what the Settings page exposes.</summary>
public sealed record ProxyOptions
{
/// <summary>Selection strategy.</summary>
public ProxyRotation Rotation { get; init; } = ProxyRotation.Sticky;
/// <summary>Liveness policy.</summary>
public ProxyHealthCheck HealthCheck { get; init; } = ProxyHealthCheck.Pool;
/// <summary>Protocols to keep when loading sources.</summary>
public ProxyProtocolFilter Protocols { get; init; } = ProxyProtocolFilter.All;
/// <summary>ISO country codes to keep. Empty means "all".</summary>
public IReadOnlyList<string> Countries { get; init; } = [];
/// <summary>URL fetched to decide whether a proxy works.</summary>
/// <remarks>
/// Defaults to a plain-HTTP 204 endpoint: it is tiny, and requiring TLS would fail every
/// proxy that cannot do CONNECT rather than every proxy that is actually dead.
/// </remarks>
public Uri ProbeUrl { get; init; } = new("http://www.gstatic.com/generate_204");
/// <summary>Per-proxy probe timeout.</summary>
public TimeSpan ProbeTimeout { get; init; } = TimeSpan.FromSeconds(8);
/// <summary>How many probes run at once during a pool sweep.</summary>
public int ProbeConcurrency { get; init; } = 64;
/// <summary>Consecutive failures tolerated before an entry is quarantined.</summary>
public int FailuresBeforeQuarantine { get; init; } = 2;
/// <summary>First quarantine window; doubles with each further consecutive failure.</summary>
public TimeSpan BaseQuarantine { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>Ceiling for the quarantine window.</summary>
public TimeSpan MaxQuarantine { get; init; } = TimeSpan.FromMinutes(15);
/// <summary>
/// Proxies tried per <see cref="IProxyPool.AcquireAsync"/> call under
/// <see cref="ProxyHealthCheck.Lazy"/> before giving up.
/// </summary>
public int LazyProbeAttempts { get; init; } = 5;
/// <summary>Whether the remote feed is consulted at all.</summary>
public bool UseFeed { get; init; } = true;
/// <summary>Validates the options, throwing on values that would misbehave silently.</summary>
/// <exception cref="ArgumentOutOfRangeException">A numeric option is out of range.</exception>
public ProxyOptions Validated()
{
ArgumentOutOfRangeException.ThrowIfLessThan(ProbeConcurrency, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(LazyProbeAttempts, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(FailuresBeforeQuarantine, 1);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(ProbeTimeout, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfGreaterThan(BaseQuarantine, MaxQuarantine);
return this;
}
/// <summary>Maps a protocol onto its filter flag.</summary>
public static ProxyProtocolFilter ToFlag(ProxyProtocol protocol) =>
protocol switch
{
ProxyProtocol.Http => ProxyProtocolFilter.Http,
ProxyProtocol.Https => ProxyProtocolFilter.Https,
ProxyProtocol.Socks4 => ProxyProtocolFilter.Socks4,
ProxyProtocol.Socks5 => ProxyProtocolFilter.Socks5,
_ => ProxyProtocolFilter.None,
};
}
+340
View File
@@ -0,0 +1,340 @@
using AvParser.Core.Proxies.Selection;
namespace AvParser.Core.Proxies;
/// <inheritdoc cref="IProxyPool" />
public sealed class ProxyPool : IProxyPool
{
private readonly IReadOnlyList<IProxySource> _sources;
private readonly IProxyProbe _probe;
private readonly TimeProvider _time;
private readonly Random? _random;
private readonly Lock _gate = new();
private readonly Dictionary<string, ProxyEntry> _byKey = new(StringComparer.Ordinal);
private List<ProxyEntry> _entries = [];
private IProxySelectionStrategy _strategy;
private ProxyOptions _options;
/// <summary>Creates a pool over the given sources.</summary>
/// <param name="sources">Every list the app knows about; order decides nothing.</param>
/// <param name="probe">Liveness checker.</param>
/// <param name="options">Initial options.</param>
/// <param name="timeProvider">Clock; tests inject a fake one to exercise quarantine expiry.</param>
/// <param name="random">Randomness for weighted selection; tests seed it.</param>
public ProxyPool(
IEnumerable<IProxySource> sources,
IProxyProbe probe,
ProxyOptions? options = null,
TimeProvider? timeProvider = null,
Random? random = null
)
{
ArgumentNullException.ThrowIfNull(sources);
_sources = sources.ToArray();
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
_time = timeProvider ?? TimeProvider.System;
_random = random;
_options = (options ?? new ProxyOptions()).Validated();
_strategy = ProxySelectionStrategyFactory.Create(_options.Rotation, random);
}
/// <inheritdoc />
public IReadOnlyList<ProxyEntry> Entries
{
get
{
lock (_gate)
{
return _entries;
}
}
}
/// <inheritdoc />
public ProxyOptions Options
{
get
{
lock (_gate)
{
return _options;
}
}
}
/// <inheritdoc />
public event EventHandler? Changed;
/// <inheritdoc />
public void Configure(ProxyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var validated = options.Validated();
lock (_gate)
{
var rotationChanged = validated.Rotation != _options.Rotation;
_options = validated;
if (rotationChanged)
{
_strategy = ProxySelectionStrategyFactory.Create(validated.Rotation, _random);
}
}
RaiseChanged();
}
/// <inheritdoc />
public async Task<int> RefreshAsync(CancellationToken cancellationToken = default)
{
var options = Options;
var collected = new List<(ProxyEndpoint Endpoint, ProxySourceKind Kind)>();
foreach (var source in _sources)
{
if (source.Kind == ProxySourceKind.Feed && !options.UseFeed)
{
continue;
}
var proxies = await source.GetProxiesAsync(cancellationToken).ConfigureAwait(false);
foreach (var endpoint in proxies)
{
collected.Add((endpoint, source.Kind));
}
}
int count;
lock (_gate)
{
var next = new List<ProxyEntry>(collected.Count);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var (endpoint, kind) in collected)
{
if (!Matches(endpoint, options) || !seen.Add(endpoint.Key))
{
continue;
}
// Reuse the existing entry so a refresh does not wipe out everything the pool
// has learned — free lists are republished every few minutes.
if (_byKey.TryGetValue(endpoint.Key, out var existing))
{
next.Add(existing);
}
else
{
var created = new ProxyEntry(endpoint, kind);
_byKey[endpoint.Key] = created;
next.Add(created);
}
}
foreach (var key in _byKey.Keys.Where(key => !seen.Contains(key)).ToArray())
{
_byKey.Remove(key);
}
_entries = next;
_strategy.Reset();
count = next.Count;
}
RaiseChanged();
return count;
}
/// <inheritdoc />
public async Task<ProxyLease?> AcquireAsync(CancellationToken cancellationToken = default)
{
var options = Options;
if (options.HealthCheck == ProxyHealthCheck.Pool)
{
var entry = SelectAvailable();
return entry is null ? null : new ProxyLease(this, entry);
}
// Lazy: verify the pick before handing it over, stepping past dead ones.
for (var attempt = 0; attempt < options.LazyProbeAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
var entry = SelectAvailable();
if (entry is null)
{
return null;
}
if (entry.Health == ProxyHealthState.Alive)
{
return new ProxyLease(this, entry);
}
var result = await _probe.ProbeAsync(entry.Endpoint, options, cancellationToken).ConfigureAwait(false);
var now = _time.GetUtcNow();
if (result.Alive)
{
entry.RecordProbe(now, alive: true, result.Latency, error: null);
RaiseChanged();
return new ProxyLease(this, entry);
}
// Same reasoning as the sweep: this is a probe verdict, so it sidelines the entry
// without inflating the request counters that weighted selection reads.
entry.RecordProbe(now, alive: false, latency: null, result.Error, options.BaseQuarantine);
lock (_gate)
{
_strategy.Report(entry, success: false);
}
RaiseChanged();
}
return null;
}
/// <inheritdoc />
public async Task<int> SweepAsync(
IProgress<ProxySweepProgress>? progress = null,
CancellationToken cancellationToken = default
)
{
var options = Options;
var targets = Entries;
var total = targets.Count;
if (total == 0)
{
progress?.Report(new ProxySweepProgress(0, 0, 0));
return 0;
}
using var limiter = new SemaphoreSlim(options.ProbeConcurrency, options.ProbeConcurrency);
var checkedCount = 0;
var aliveCount = 0;
var work = targets.Select(async entry =>
{
await limiter.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var result = await _probe.ProbeAsync(entry.Endpoint, options, cancellationToken).ConfigureAwait(false);
// A failed sweep probe sidelines the entry for one base window rather than
// counting as a request failure: it is evidence about the proxy, not usage of it.
entry.RecordProbe(
_time.GetUtcNow(),
result.Alive,
result.Latency,
result.Error,
quarantineOnFailure: options.BaseQuarantine
);
if (result.Alive)
{
Interlocked.Increment(ref aliveCount);
}
}
finally
{
limiter.Release();
progress?.Report(
new ProxySweepProgress(
Interlocked.Increment(ref checkedCount),
total,
Volatile.Read(ref aliveCount)
)
);
}
});
await Task.WhenAll(work).ConfigureAwait(false);
lock (_gate)
{
// A sweep can invalidate a sticky pick, so make the strategy re-choose.
_strategy.Reset();
}
RaiseChanged();
return aliveCount;
}
/// <summary>Records the outcome of a lease. Called by <see cref="ProxyLease"/>.</summary>
internal void ReportOutcome(ProxyEntry entry, bool success, TimeSpan? latency, string? error)
{
var options = Options;
var now = _time.GetUtcNow();
if (success)
{
entry.RecordSuccess(now, latency);
}
else
{
entry.RecordFailure(
now,
options.BaseQuarantine,
options.MaxQuarantine,
options.FailuresBeforeQuarantine,
error
);
}
lock (_gate)
{
_strategy.Report(entry, success);
}
RaiseChanged();
}
/// <summary>Whether an endpoint passes the protocol and country filters.</summary>
public static bool Matches(ProxyEndpoint endpoint, ProxyOptions options)
{
ArgumentNullException.ThrowIfNull(endpoint);
ArgumentNullException.ThrowIfNull(options);
if (
options.Protocols != ProxyProtocolFilter.None
&& !options.Protocols.HasFlag(ProxyOptions.ToFlag(endpoint.Protocol))
)
{
return false;
}
return options.Countries.Count == 0
|| (
endpoint.Country is { } country && options.Countries.Contains(country, StringComparer.OrdinalIgnoreCase)
);
}
private ProxyEntry? SelectAvailable()
{
lock (_gate)
{
var now = _time.GetUtcNow();
// Best-first ordering: sticky selection takes the head of this list, and the other
// strategies benefit from healthy proxies being clustered at the front.
var candidates = _entries
.Where(entry => entry.IsAvailable(now))
.OrderByDescending(entry => entry.Health == ProxyHealthState.Alive)
.ThenBy(entry => entry.Latency ?? TimeSpan.MaxValue)
.ThenByDescending(entry => entry.Endpoint.Score)
.ToArray();
return _strategy.Pick(candidates);
}
}
private void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
}
@@ -0,0 +1,38 @@
namespace AvParser.Core.Proxies.Selection;
/// <summary>Decides which proxy to hand out next.</summary>
/// <remarks>
/// Stateful by design — sticky selection has to remember its pick, and round-robin its cursor.
/// Implementations are called under the pool's lock and need no locking of their own.
/// </remarks>
public interface IProxySelectionStrategy
{
/// <summary>Which setting this strategy implements.</summary>
ProxyRotation Kind { get; }
/// <summary>Picks from the already-filtered available candidates, or <see langword="null"/> if none.</summary>
ProxyEntry? Pick(IReadOnlyList<ProxyEntry> candidates);
/// <summary>Tells the strategy how the handed-out proxy fared.</summary>
void Report(ProxyEntry entry, bool success);
/// <summary>Drops any remembered state, e.g. after the pool is reloaded.</summary>
void Reset();
}
/// <summary>Builds the strategy named by <see cref="ProxyOptions.Rotation"/>.</summary>
public static class ProxySelectionStrategyFactory
{
/// <summary>Creates a strategy instance.</summary>
/// <param name="rotation">Which strategy to build.</param>
/// <param name="random">Randomness for <see cref="ProxyRotation.WeightedRandom"/>; tests pass a seeded instance.</param>
/// <exception cref="ArgumentOutOfRangeException">Unknown rotation value.</exception>
public static IProxySelectionStrategy Create(ProxyRotation rotation, Random? random = null) =>
rotation switch
{
ProxyRotation.Sticky => new StickyProxySelection(),
ProxyRotation.RoundRobin => new RoundRobinProxySelection(),
ProxyRotation.WeightedRandom => new WeightedRandomProxySelection(random),
_ => throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Unknown rotation strategy."),
};
}
@@ -0,0 +1,43 @@
namespace AvParser.Core.Proxies.Selection;
/// <summary>
/// Advances one position through the candidates on every acquisition.
/// </summary>
/// <remarks>
/// Spreads requests evenly, which is what rate limits care about. The cursor is kept as a
/// monotonic counter reduced modulo the candidate count rather than as an index into the list,
/// so a list that shrinks between calls cannot throw or silently skip entries.
/// </remarks>
public sealed class RoundRobinProxySelection : IProxySelectionStrategy
{
private int _cursor;
/// <inheritdoc />
public ProxyRotation Kind => ProxyRotation.RoundRobin;
/// <inheritdoc />
public ProxyEntry? Pick(IReadOnlyList<ProxyEntry> candidates)
{
ArgumentNullException.ThrowIfNull(candidates);
if (candidates.Count == 0)
{
return null;
}
var index = (int)((uint)_cursor % (uint)candidates.Count);
_cursor = _cursor == int.MaxValue ? 0 : _cursor + 1;
return candidates[index];
}
/// <inheritdoc />
public void Report(ProxyEntry entry, bool success)
{
// Position is independent of outcome: a failing proxy is dropped by the pool's own
// quarantine, not by rewinding the cursor.
}
/// <inheritdoc />
public void Reset() => _cursor = 0;
}
@@ -0,0 +1,45 @@
namespace AvParser.Core.Proxies.Selection;
/// <summary>
/// Holds one proxy until it fails.
/// </summary>
/// <remarks>
/// The default because it is the only strategy that keeps a site's session coherent: rotating on
/// every request changes the apparent client mid-session, which reliably triggers re-logins and
/// captchas on anything that tracks cookies.
/// </remarks>
public sealed class StickyProxySelection : IProxySelectionStrategy
{
private ProxyEntry? _current;
/// <inheritdoc />
public ProxyRotation Kind => ProxyRotation.Sticky;
/// <inheritdoc />
public ProxyEntry? Pick(IReadOnlyList<ProxyEntry> candidates)
{
ArgumentNullException.ThrowIfNull(candidates);
// Keep the current pick only while it is still among the available candidates: a refresh
// or a quarantine may have taken it out from under us.
if (_current is not null && candidates.Contains(_current))
{
return _current;
}
_current = candidates.Count > 0 ? candidates[0] : null;
return _current;
}
/// <inheritdoc />
public void Report(ProxyEntry entry, bool success)
{
if (!success && ReferenceEquals(entry, _current))
{
_current = null;
}
}
/// <inheritdoc />
public void Reset() => _current = null;
}
@@ -0,0 +1,83 @@
namespace AvParser.Core.Proxies.Selection;
/// <summary>
/// Picks at random, biased towards proxies that have actually worked.
/// </summary>
/// <remarks>
/// Weight is the feed's score multiplied by the observed success rate, so a proxy the feed likes
/// but that keeps failing here drifts to the bottom without ever being excluded outright — free
/// lists recover, and a hard ban would lose them permanently.
/// </remarks>
public sealed class WeightedRandomProxySelection(Random? random = null) : IProxySelectionStrategy
{
/// <summary>Floor on the weight so an unproven proxy still gets picked occasionally.</summary>
private const double MinimumWeight = 0.05d;
private readonly Random _random = random ?? Random.Shared;
/// <inheritdoc />
public ProxyRotation Kind => ProxyRotation.WeightedRandom;
/// <inheritdoc />
public ProxyEntry? Pick(IReadOnlyList<ProxyEntry> candidates)
{
ArgumentNullException.ThrowIfNull(candidates);
if (candidates.Count == 0)
{
return null;
}
if (candidates.Count == 1)
{
return candidates[0];
}
var weights = new double[candidates.Count];
var total = 0d;
for (var i = 0; i < candidates.Count; i++)
{
var weight = WeightOf(candidates[i]);
weights[i] = weight;
total += weight;
}
var target = _random.NextDouble() * total;
var running = 0d;
for (var i = 0; i < weights.Length; i++)
{
running += weights[i];
if (target < running)
{
return candidates[i];
}
}
// Floating-point drift can leave `target` a hair past the final boundary.
return candidates[^1];
}
/// <inheritdoc />
public void Report(ProxyEntry entry, bool success)
{
// The weight is derived from the entry's own counters, which the pool already updated.
}
/// <inheritdoc />
public void Reset()
{
// Stateless beyond the RNG.
}
/// <summary>Weight of a single entry. Exposed so the tests can assert the ordering.</summary>
public static double WeightOf(ProxyEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
// Feed scores are small integers and frequently zero; +1 keeps an unscored proxy in play.
var score = Math.Max(0, entry.Endpoint.Score) + 1d;
return Math.Max(MinimumWeight, score * entry.SuccessRate);
}
}
+46
View File
@@ -1,3 +1,5 @@
using AvParser.Core.Proxies;
namespace AvParser.Core.Settings; namespace AvParser.Core.Settings;
/// <summary>Theme preference. <see cref="System"/> follows the OS setting.</summary> /// <summary>Theme preference. <see cref="System"/> follows the OS setting.</summary>
@@ -39,4 +41,48 @@ public sealed record AppSettings
/// <summary>Minimum Serilog level, as a Serilog level name.</summary> /// <summary>Minimum Serilog level, as a Serilog level name.</summary>
public string MinimumLogLevel { get; init; } = "Information"; 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.
/// </remarks>
public ProxyOptions ToProxyOptions()
{
var probeUrl = Uri.TryCreate(ProxyProbeUrl, UriKind.Absolute, out var parsed)
? parsed
: new ProxyOptions().ProbeUrl;
return new ProxyOptions
{
Rotation = ProxyRotation,
HealthCheck = ProxyHealthCheck,
UseFeed = ProxyUseFeed,
Protocols = ProxyProtocols,
ProbeUrl = probeUrl,
ProbeTimeout = TimeSpan.FromSeconds(Math.Clamp(ProxyProbeTimeoutSeconds, 1, 120)),
ProbeConcurrency = Math.Clamp(ProxyProbeConcurrency, 1, 512),
}.Validated();
}
} }
@@ -7,8 +7,15 @@
<ProjectReference Include="..\AvParser.Core\AvParser.Core.csproj" /> <ProjectReference Include="..\AvParser.Core\AvParser.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- The feed parsing is internal on purpose — it is an implementation detail of the source,
not API — but it is also the part most worth testing against captured payloads. -->
<InternalsVisibleTo Include="AvParser.Infrastructure.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<!-- Signal<T> / operators. The UI-free half of the ReactiveUI stack — no Avalonia here. --> <!-- Signal<T> / operators. The UI-free half of the ReactiveUI stack — no Avalonia here. -->
<PackageReference Include="ReactiveUI.Primitives" /> <PackageReference Include="ReactiveUI.Primitives" />
@@ -1,4 +1,6 @@
using AvParser.Core.Proxies;
using AvParser.Core.Settings; using AvParser.Core.Settings;
using AvParser.Infrastructure.Proxies;
using AvParser.Infrastructure.Settings; using AvParser.Infrastructure.Settings;
using AvParser.Infrastructure.Storage; using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -31,6 +33,49 @@ public static class InfrastructureServiceCollectionExtensions
sp.GetRequiredService<ILogger<JsonSettingsService>>() sp.GetRequiredService<ILogger<JsonSettingsService>>()
)); ));
services.AddAvParserProxies();
return services;
}
/// <summary>Registers the proxy sources, the probe, the pool and the proxied client factory.</summary>
/// <remarks>
/// The feed source gets a pooled <see cref="HttpClient"/> because it always talks to the same
/// CDN host. The probe deliberately does not: its handler carries the proxy, so it has to
/// build one per check.
/// </remarks>
public static IServiceCollection AddAvParserProxies(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services
.AddHttpClient<ProxiflyProxySource>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("AvParser/0.1");
})
.ConfigurePrimaryHttpMessageHandler(() =>
new SocketsHttpHandler { AutomaticDecompression = System.Net.DecompressionMethods.All }
);
services.AddSingleton<CustomProxySource>();
services.AddSingleton<IMutableProxySource>(sp => sp.GetRequiredService<CustomProxySource>());
// Registration order here is the order the pool merges sources; the custom list comes
// last so a user-entered address wins over a feed entry for the same host and port.
services.AddSingleton<IProxySource>(sp => sp.GetRequiredService<ProxiflyProxySource>());
services.AddSingleton<IProxySource>(sp => sp.GetRequiredService<CustomProxySource>());
services.AddSingleton<IProxyProbe, HttpProxyProbe>();
services.AddSingleton<IProxyPool>(sp => new ProxyPool(
sp.GetServices<IProxySource>(),
sp.GetRequiredService<IProxyProbe>(),
sp.GetRequiredService<ISettingsService>().Current.ToProxyOptions()
));
services.AddSingleton<IProxiedHttpClientFactory, ProxiedHttpClientFactory>();
return services; return services;
} }
} }
@@ -0,0 +1,273 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging;
namespace AvParser.Infrastructure.Proxies;
/// <summary>One user-entered proxy, as persisted.</summary>
/// <param name="Address">Address in <c>scheme://host:port</c> form.</param>
/// <param name="Username">Optional username.</param>
/// <param name="Password">Optional password.</param>
/// <param name="Note">Free-text note shown in the UI.</param>
public sealed record CustomProxyRecord(
string Address,
string? Username = null,
string? Password = null,
string? Note = null
);
/// <summary>Source-generated serialiser metadata for the user's proxy list.</summary>
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(List<CustomProxyRecord>))]
internal sealed partial class CustomProxyJsonContext : JsonSerializerContext;
/// <summary>
/// The user's own proxy list, persisted next to the settings.
/// </summary>
/// <remarks>
/// A separate file rather than a field inside settings.json: it is a list people paste into,
/// back up and share, and burying it in the settings blob makes all three awkward. Credentials
/// are stored in clear text — the same as every other proxy client, and worth knowing.
/// </remarks>
public sealed class CustomProxySource : IMutableProxySource, IDisposable
{
private readonly IAppPaths _paths;
private readonly ILogger<CustomProxySource> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private List<CustomProxyRecord>? _records;
/// <summary>Creates the source.</summary>
public CustomProxySource(IAppPaths paths, ILogger<CustomProxySource> logger)
{
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public string Id => "custom";
/// <inheritdoc />
public string DisplayName => "Custom list";
/// <inheritdoc />
public ProxySourceKind Kind => ProxySourceKind.Custom;
/// <inheritdoc />
public async Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var records = Load();
var endpoints = new List<ProxyEndpoint>(records.Count);
foreach (var record in records)
{
if (ProxyEndpoint.TryParse(record.Address, out var endpoint))
{
endpoints.Add(
endpoint with
{
Username = record.Username ?? endpoint.Username,
Password = record.Password ?? endpoint.Password,
}
);
}
else
{
_logger.LogWarning("Skipping unparsable custom proxy {Address}", record.Address);
}
}
return endpoints;
}
finally
{
_gate.Release();
}
}
/// <inheritdoc />
public async Task<int> AddAsync(IEnumerable<ProxyEndpoint> endpoints, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(endpoints);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var records = Load();
var known = new HashSet<string>(
records.Where(r => ProxyEndpoint.TryParse(r.Address, out _)).Select(KeyOf),
StringComparer.Ordinal
);
var added = 0;
foreach (var endpoint in endpoints)
{
if (!known.Add(endpoint.Key))
{
continue;
}
records.Add(new CustomProxyRecord(endpoint.ToString(), endpoint.Username, endpoint.Password));
added++;
}
if (added > 0)
{
await SaveAsync(records, cancellationToken).ConfigureAwait(false);
}
return added;
}
finally
{
_gate.Release();
}
}
/// <inheritdoc />
public async Task<bool> RemoveAsync(ProxyEndpoint endpoint, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(endpoint);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var records = Load();
var removed = records.RemoveAll(record =>
ProxyEndpoint.TryParse(record.Address, out var parsed)
&& string.Equals(parsed.Key, endpoint.Key, StringComparison.Ordinal)
);
if (removed > 0)
{
await SaveAsync(records, cancellationToken).ConfigureAwait(false);
}
return removed > 0;
}
finally
{
_gate.Release();
}
}
/// <inheritdoc />
public async Task ClearAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await SaveAsync([], cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
/// <inheritdoc />
public void Dispose() => _gate.Dispose();
/// <summary>
/// Parses a pasted blob into endpoints, one per line.
/// </summary>
/// <returns>The endpoints that parsed, and the lines that did not.</returns>
/// <remarks>
/// Reports the bad lines instead of dropping them: people paste lists of hundreds, and a
/// silent "added 97 of 100" is impossible to act on.
/// </remarks>
public static (IReadOnlyList<ProxyEndpoint> Parsed, IReadOnlyList<string> Rejected) ParseList(string? text)
{
var parsed = new List<ProxyEndpoint>();
var rejected = new List<string>();
if (string.IsNullOrWhiteSpace(text))
{
return (parsed, rejected);
}
// Lines first, then separators within a line. Doing it the other way round splits
// "# my proxies" into three tokens and reports two of them as invalid addresses.
foreach (var rawLine in text.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("//", StringComparison.Ordinal))
{
continue;
}
foreach (var token in line.Split([',', ';', ' ', '\t'], StringSplitOptions.RemoveEmptyEntries))
{
if (ProxyEndpoint.TryParse(token, out var endpoint))
{
parsed.Add(endpoint);
}
else
{
rejected.Add(token);
}
}
}
return (parsed, rejected);
}
private static string KeyOf(CustomProxyRecord record) =>
ProxyEndpoint.TryParse(record.Address, out var endpoint) ? endpoint.Key : record.Address;
private List<CustomProxyRecord> Load()
{
if (_records is not null)
{
return _records;
}
try
{
if (!File.Exists(_paths.CustomProxiesFile))
{
_records = [];
return _records;
}
var json = File.ReadAllText(_paths.CustomProxiesFile);
_records = JsonSerializer.Deserialize(json, CustomProxyJsonContext.Default.ListCustomProxyRecord) ?? [];
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
_logger.LogWarning(
ex,
"Could not read {Path}; starting with an empty custom list",
_paths.CustomProxiesFile
);
_records = [];
}
return _records;
}
private async Task SaveAsync(List<CustomProxyRecord> records, CancellationToken cancellationToken)
{
_records = records;
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_paths.CustomProxiesFile)!);
var temp = _paths.CustomProxiesFile + ".tmp";
var json = JsonSerializer.Serialize(records, CustomProxyJsonContext.Default.ListCustomProxyRecord);
await File.WriteAllTextAsync(temp, json, cancellationToken).ConfigureAwait(false);
File.Move(temp, _paths.CustomProxiesFile, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Could not write {Path}", _paths.CustomProxiesFile);
}
}
}
@@ -0,0 +1,113 @@
using System.Diagnostics;
using System.Net;
using AvParser.Core.Proxies;
namespace AvParser.Infrastructure.Proxies;
/// <summary>Builds the .NET plumbing needed to route a request through a proxy.</summary>
public static class ProxyHandlerFactory
{
/// <summary>Wraps an endpoint in a <see cref="WebProxy"/>, attaching credentials if present.</summary>
public static WebProxy CreateWebProxy(ProxyEndpoint endpoint)
{
ArgumentNullException.ThrowIfNull(endpoint);
var proxy = new WebProxy(endpoint.Uri);
if (endpoint.HasCredentials)
{
proxy.Credentials = new NetworkCredential(endpoint.Username, endpoint.Password);
}
return proxy;
}
/// <summary>
/// Creates a handler bound to one proxy, or a direct handler when <paramref name="endpoint"/> is null.
/// </summary>
/// <remarks>
/// A fresh handler per proxy is unavoidable — the proxy is a property of the handler, not of
/// the request — so connection pooling cannot be shared across proxies.
/// SOCKS is handled natively: .NET understands the socks4/socks4a/socks5 schemes.
/// </remarks>
public static SocketsHttpHandler CreateHandler(ProxyEndpoint? endpoint, TimeSpan connectTimeout)
{
var handler = new SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectTimeout = connectTimeout,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
AutomaticDecompression = DecompressionMethods.All,
};
if (endpoint is null)
{
handler.UseProxy = false;
return handler;
}
handler.UseProxy = true;
handler.Proxy = CreateWebProxy(endpoint);
return handler;
}
}
/// <summary>Checks a proxy by fetching <see cref="ProxyOptions.ProbeUrl"/> through it.</summary>
public sealed class HttpProxyProbe : IProxyProbe
{
/// <inheritdoc />
public async Task<ProxyProbeResult> ProbeAsync(
ProxyEndpoint endpoint,
ProxyOptions options,
CancellationToken cancellationToken = default
)
{
ArgumentNullException.ThrowIfNull(endpoint);
ArgumentNullException.ThrowIfNull(options);
using var handler = ProxyHandlerFactory.CreateHandler(endpoint, options.ProbeTimeout);
using var client = new HttpClient(handler, disposeHandler: false) { Timeout = options.ProbeTimeout };
var stopwatch = Stopwatch.StartNew();
try
{
using var response = await client
.GetAsync(options.ProbeUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
stopwatch.Stop();
return response.IsSuccessStatusCode
? ProxyProbeResult.Success(stopwatch.Elapsed)
: ProxyProbeResult.Failure($"HTTP {(int)response.StatusCode}");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The caller pulled the plug — that is not a verdict on the proxy.
throw;
}
catch (OperationCanceledException)
{
return ProxyProbeResult.Failure("timeout");
}
catch (HttpRequestException ex)
{
return ProxyProbeResult.Failure(Describe(ex));
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or NotSupportedException)
{
return ProxyProbeResult.Failure(ex.Message);
}
}
private static string Describe(HttpRequestException exception) =>
exception.HttpRequestError switch
{
HttpRequestError.ProxyTunnelError => "proxy refused CONNECT",
HttpRequestError.ConnectionError => "connection failed",
HttpRequestError.NameResolutionError => "DNS failed",
HttpRequestError.SecureConnectionError => "TLS failed",
_ => exception.Message,
};
}
@@ -0,0 +1,56 @@
using AvParser.Core.Proxies;
namespace AvParser.Infrastructure.Proxies;
/// <summary>Creates <see cref="HttpClient"/> instances bound to a specific proxy.</summary>
/// <remarks>
/// Not <c>IHttpClientFactory</c>: that exists to share handlers across requests, and a per-proxy
/// handler is the opposite of shareable. This is the seam the parser layer will use once it
/// starts making real requests.
/// </remarks>
public interface IProxiedHttpClientFactory
{
/// <summary>Creates a client routed through <paramref name="endpoint"/>, or direct when null.</summary>
/// <remarks>The caller owns the returned client and must dispose it.</remarks>
HttpClient Create(ProxyEndpoint? endpoint, TimeSpan? timeout = null);
/// <summary>
/// Takes a proxy from the pool and returns a client bound to it, or a direct client when the
/// pool has nothing usable.
/// </summary>
/// <remarks>
/// The lease comes back with the client so the caller can report the outcome — without that
/// the pool never learns which proxies work.
/// </remarks>
Task<(HttpClient Client, ProxyLease? Lease)> CreateFromPoolAsync(
TimeSpan? timeout = null,
CancellationToken cancellationToken = default
);
}
/// <inheritdoc cref="IProxiedHttpClientFactory" />
public sealed class ProxiedHttpClientFactory(IProxyPool pool) : IProxiedHttpClientFactory
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
private readonly IProxyPool _pool = pool ?? throw new ArgumentNullException(nameof(pool));
/// <inheritdoc />
public HttpClient Create(ProxyEndpoint? endpoint, TimeSpan? timeout = null)
{
var effective = timeout ?? DefaultTimeout;
var handler = ProxyHandlerFactory.CreateHandler(endpoint, effective);
return new HttpClient(handler, disposeHandler: true) { Timeout = effective };
}
/// <inheritdoc />
public async Task<(HttpClient Client, ProxyLease? Lease)> CreateFromPoolAsync(
TimeSpan? timeout = null,
CancellationToken cancellationToken = default
)
{
var lease = await _pool.AcquireAsync(cancellationToken).ConfigureAwait(false);
return (Create(lease?.Endpoint, timeout), lease);
}
}
@@ -0,0 +1,186 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AvParser.Core.Proxies;
using Microsoft.Extensions.Logging;
namespace AvParser.Infrastructure.Proxies;
/// <summary>One record of the proxifly feed.</summary>
internal sealed record ProxiflyRecord
{
public string? Proxy { get; init; }
public string? Protocol { get; init; }
public string? Ip { get; init; }
public int Port { get; init; }
public bool Https { get; init; }
public string? Anonymity { get; init; }
public int Score { get; init; }
public ProxiflyGeolocation? Geolocation { get; init; }
}
/// <summary>Geolocation block of a proxifly record.</summary>
internal sealed record ProxiflyGeolocation
{
public string? Country { get; init; }
public string? City { get; init; }
}
/// <summary>Source-generated serialiser metadata for the proxifly feed.</summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(ProxiflyRecord[]))]
internal sealed partial class ProxiflyJsonContext : JsonSerializerContext;
/// <summary>
/// Reads the public list published at github.com/proxifly/free-proxy-list.
/// </summary>
/// <remarks>
/// Fetches the combined <c>all/data.json</c> and filters locally rather than requesting a
/// per-protocol slice: the feed is regenerated every few minutes, so one conditional request for
/// the whole list beats four that can disagree with each other mid-publish.
/// </remarks>
public sealed class ProxiflyProxySource : IProxySource, IDisposable
{
/// <summary>Served from jsDelivr rather than raw.githubusercontent.com — a CDN meant for this.</summary>
public static readonly Uri FeedUrl = new(
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/all/data.json"
);
/// <summary>Upstream refreshes every five minutes; asking more often only burns bandwidth.</summary>
public static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5);
private readonly HttpClient _http;
private readonly ILogger<ProxiflyProxySource> _logger;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _fetchLock = new(1, 1);
private IReadOnlyList<ProxyEndpoint> _cached = [];
private DateTimeOffset? _fetchedAtUtc;
/// <summary>Creates the source.</summary>
public ProxiflyProxySource(HttpClient http, ILogger<ProxiflyProxySource> logger, TimeProvider? timeProvider = null)
{
_http = http ?? throw new ArgumentNullException(nameof(http));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_time = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string Id => "proxifly";
/// <inheritdoc />
public string DisplayName => "Proxifly free-proxy-list";
/// <inheritdoc />
public ProxySourceKind Kind => ProxySourceKind.Feed;
/// <summary>When the feed was last downloaded, or <see langword="null"/> if never.</summary>
public DateTimeOffset? FetchedAtUtc => _fetchedAtUtc;
/// <inheritdoc />
public async Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default)
{
if (IsFresh())
{
return _cached;
}
await _fetchLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Another caller may have refreshed while we waited for the lock.
if (IsFresh())
{
return _cached;
}
var endpoints = await FetchAsync(cancellationToken).ConfigureAwait(false);
_cached = endpoints;
_fetchedAtUtc = _time.GetUtcNow();
return endpoints;
}
finally
{
_fetchLock.Release();
}
}
/// <summary>Drops the cache so the next call re-downloads.</summary>
public void Invalidate() => _fetchedAtUtc = null;
/// <inheritdoc />
/// <remarks>The <see cref="HttpClient"/> is owned by the container, so only the lock is released here.</remarks>
public void Dispose() => _fetchLock.Dispose();
/// <summary>Converts one feed record into an endpoint, or <see langword="null"/> if unusable.</summary>
/// <remarks>Internal so the parsing can be tested against captured feed samples without HTTP.</remarks>
internal static ProxyEndpoint? ToEndpoint(ProxiflyRecord record)
{
if (record.Ip is not { Length: > 0 } host || record.Port is < 1 or > 65535)
{
// The "proxy" field carries the same address; fall back to it when the parts are absent.
return ProxyEndpoint.TryParse(record.Proxy, out var parsed) ? Decorate(parsed, record) : null;
}
if (!ProxyEndpoint.TryParseProtocol(record.Protocol, out var protocol))
{
return null;
}
return Decorate(new ProxyEndpoint(protocol, host, record.Port), record);
}
/// <summary>Parses a whole feed payload.</summary>
internal static IReadOnlyList<ProxyEndpoint> ParseFeed(string json)
{
var records = JsonSerializer.Deserialize(json, ProxiflyJsonContext.Default.ProxiflyRecordArray);
if (records is null)
{
return [];
}
var endpoints = new List<ProxyEndpoint>(records.Length);
foreach (var record in records)
{
if (ToEndpoint(record) is { } endpoint)
{
endpoints.Add(endpoint);
}
}
return endpoints;
}
private static ProxyEndpoint Decorate(ProxyEndpoint endpoint, ProxiflyRecord record) =>
endpoint with
{
Country = record.Geolocation?.Country,
City = string.Equals(record.Geolocation?.City, "Unknown", StringComparison.OrdinalIgnoreCase)
? null
: record.Geolocation?.City,
Anonymity = ProxyEndpoint.ParseAnonymity(record.Anonymity),
Score = record.Score,
};
private bool IsFresh() => _fetchedAtUtc is { } at && _time.GetUtcNow() - at < CacheTtl;
private async Task<IReadOnlyList<ProxyEndpoint>> FetchAsync(CancellationToken cancellationToken)
{
try
{
using var response = await _http.GetAsync(FeedUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var endpoints = ParseFeed(json);
_logger.LogInformation("Fetched {Count} proxies from {Source}", endpoints.Count, DisplayName);
return endpoints;
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
{
// A feed that is down must not take the app with it: keep serving whatever we had.
_logger.LogWarning(ex, "Could not fetch {Source}; keeping {Count} cached", DisplayName, _cached.Count);
return _cached;
}
}
}
@@ -13,6 +13,10 @@ public interface IAppPaths
/// <summary>Full path of the settings file.</summary> /// <summary>Full path of the settings file.</summary>
string SettingsFile { get; } string SettingsFile { get; }
/// <summary>Full path of the user's own proxy list.</summary>
/// <remarks>Kept separate from the settings file: it is a list the user edits and may want to back up or share.</remarks>
string CustomProxiesFile { get; }
/// <summary>Directory holding rolling log files.</summary> /// <summary>Directory holding rolling log files.</summary>
string LogDirectory { get; } string LogDirectory { get; }
} }
@@ -45,6 +49,7 @@ public sealed class AppPaths : IAppPaths
DataDirectory = dataDirectory; DataDirectory = dataDirectory;
SettingsFile = Path.Combine(dataDirectory, "settings.json"); SettingsFile = Path.Combine(dataDirectory, "settings.json");
CustomProxiesFile = Path.Combine(dataDirectory, "proxies.custom.json");
LogDirectory = Path.Combine(dataDirectory, "logs"); LogDirectory = Path.Combine(dataDirectory, "logs");
} }
@@ -54,6 +59,9 @@ public sealed class AppPaths : IAppPaths
/// <inheritdoc /> /// <inheritdoc />
public string SettingsFile { get; } public string SettingsFile { get; }
/// <inheritdoc />
public string CustomProxiesFile { get; }
/// <inheritdoc /> /// <inheritdoc />
public string LogDirectory { get; } public string LogDirectory { get; }
@@ -1,4 +1,5 @@
using AvParser.Core.Parsing; using AvParser.Core.Parsing;
using AvParser.Core.Proxies;
using AvParser.Core.Settings; using AvParser.Core.Settings;
using AvParser.Infrastructure.Storage; using AvParser.Infrastructure.Storage;
using AvParser.UI.Navigation; using AvParser.UI.Navigation;
@@ -37,13 +38,20 @@ public static class UiServiceCollectionExtensions
sp.GetRequiredService<ISettingsService>(), sp.GetRequiredService<ISettingsService>(),
sp.GetRequiredService<IThemeService>(), sp.GetRequiredService<IThemeService>(),
sp.GetRequiredService<IAppPaths>(), sp.GetRequiredService<IAppPaths>(),
sp.GetRequiredService<LoggingLevelSwitch>() sp.GetRequiredService<LoggingLevelSwitch>(),
sp.GetRequiredService<IProxyPool>()
));
services.AddSingleton<ProxiesViewModel>(static sp => new ProxiesViewModel(
sp.GetRequiredService<IProxyPool>(),
sp.GetRequiredService<IMutableProxySource>(),
sp.GetRequiredService<ILogger<ProxiesViewModel>>()
)); ));
services.AddSingleton<AboutViewModel>(); services.AddSingleton<AboutViewModel>();
// Order here is the order of the navigation rail; the first entry is the landing page. // Order here is the order of the navigation rail; the first entry is the landing page.
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<DashboardViewModel>()); services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<DashboardViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ParseViewModel>()); services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ParseViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ProxiesViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<SettingsViewModel>()); services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<SettingsViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<AboutViewModel>()); services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<AboutViewModel>());
+17
View File
@@ -71,6 +71,23 @@
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" /> <Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
</Style> </Style>
<!-- Status chips. Bound from data with Classes.ok / Classes.bad. -->
<Style Selector="Border.chip.ok">
<Setter Property="Background" Value="{DynamicResource AppSuccessSoftBrush}" />
</Style>
<Style Selector="Border.chip.ok TextBlock">
<Setter Property="Foreground" Value="{DynamicResource AppSuccessBrush}" />
</Style>
<Style Selector="Border.chip.bad">
<Setter Property="Background" Value="{DynamicResource AppDangerSoftBrush}" />
</Style>
<Style Selector="Border.chip.bad TextBlock">
<Setter Property="Foreground" Value="{DynamicResource AppDangerBrush}" />
</Style>
<!-- Icon glyph. Paths inherit the surrounding foreground so they follow the theme. --> <!-- Icon glyph. Paths inherit the surrounding foreground so they follow the theme. -->
<Style Selector="PathIcon.glyph"> <Style Selector="PathIcon.glyph">
<Setter Property="Width" Value="{DynamicResource IconSize}" /> <Setter Property="Width" Value="{DynamicResource IconSize}" />
+10
View File
@@ -39,6 +39,16 @@
<StreamGeometry x:Key="IconAlert">M12 2 1 21h22L12 2zm1 14.2h-2v-2h2v2zm0-3.8h-2V8.6h2v3.8z</StreamGeometry> <StreamGeometry x:Key="IconAlert">M12 2 1 21h22L12 2zm1 14.2h-2v-2h2v2zm0-3.8h-2V8.6h2v3.8z</StreamGeometry>
<StreamGeometry x:Key="IconShield">
M12 2 4 5.2v6.1c0 4.6 3.4 8.9 8 10.7 4.6-1.8 8-6.1 8-10.7V5.2L12 2zm0 2.2 6 2.4v4.7c0 3.6-2.5 7-6 8.5-3.5-1.5-6-4.9-6-8.5V6.6l6-2.4z
</StreamGeometry>
<StreamGeometry x:Key="IconRefresh">M12 5V2L8 6l4 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z</StreamGeometry>
<StreamGeometry x:Key="IconPlus">M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5z</StreamGeometry>
<StreamGeometry x:Key="IconTrash">M9 3h6l1 1h4v2H4V4h4l1-1zM6 7h12l-1 13H7L6 7z</StreamGeometry>
<StreamGeometry x:Key="IconSparkle"> <StreamGeometry x:Key="IconSparkle">
M12 2.5 13.9 9l6.6 1.9-6.6 1.9L12 19.4l-1.9-6.6L3.5 11 10.1 9 12 2.5z M12 2.5 13.9 9l6.6 1.9-6.6 1.9L12 19.4l-1.9-6.6L3.5 11 10.1 9 12 2.5z
</StreamGeometry> </StreamGeometry>
+2
View File
@@ -21,6 +21,7 @@
<SolidColorBrush x:Key="AppDangerBrush" Color="#DC2626" /> <SolidColorBrush x:Key="AppDangerBrush" Color="#DC2626" />
<SolidColorBrush x:Key="AppDangerSoftBrush" Color="#FDECEC" /> <SolidColorBrush x:Key="AppDangerSoftBrush" Color="#FDECEC" />
<SolidColorBrush x:Key="AppSuccessBrush" Color="#15803D" /> <SolidColorBrush x:Key="AppSuccessBrush" Color="#15803D" />
<SolidColorBrush x:Key="AppSuccessSoftBrush" Color="#E4F4EA" />
</ResourceDictionary> </ResourceDictionary>
<ResourceDictionary x:Key="Dark"> <ResourceDictionary x:Key="Dark">
@@ -36,6 +37,7 @@
<SolidColorBrush x:Key="AppDangerBrush" Color="#F87171" /> <SolidColorBrush x:Key="AppDangerBrush" Color="#F87171" />
<SolidColorBrush x:Key="AppDangerSoftBrush" Color="#33191B" /> <SolidColorBrush x:Key="AppDangerSoftBrush" Color="#33191B" />
<SolidColorBrush x:Key="AppSuccessBrush" Color="#4ADE80" /> <SolidColorBrush x:Key="AppSuccessBrush" Color="#4ADE80" />
<SolidColorBrush x:Key="AppSuccessSoftBrush" Color="#16281C" />
</ResourceDictionary> </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries> </ResourceDictionary.ThemeDictionaries>
@@ -0,0 +1,363 @@
using System.Collections.ObjectModel;
using System.Globalization;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
using ReactiveUI.Primitives.Signals;
using ReactiveUI.SourceGenerators;
namespace AvParser.UI.ViewModels;
/// <summary>Health filter offered by the proxy list.</summary>
public enum ProxyHealthFilter
{
/// <summary>No filtering.</summary>
All,
/// <summary>Only proxies whose last check succeeded.</summary>
Alive,
/// <summary>Only proxies whose last check failed.</summary>
Dead,
/// <summary>Only proxies that were never checked.</summary>
Unchecked,
}
/// <summary>Manages the proxy pool: refresh from the feed, probe, and edit the custom list.</summary>
public partial class ProxiesViewModel : PageViewModel, IDisposable
{
/// <summary>
/// Rows rendered at once. A free feed carries a few thousand proxies, and a virtualising list
/// still pays to build every row view model, so the view is capped and says so.
/// </summary>
private const int MaxDisplayedRows = 500;
private readonly IProxyPool _pool;
private readonly IMutableProxySource _customSource;
private readonly ILogger<ProxiesViewModel> _logger;
private readonly ISequencer _mainThread;
private readonly Dictionary<string, ProxyRowViewModel> _rows = new(StringComparer.Ordinal);
private readonly Signal<RxVoid> _poolChanged = new();
/// <summary>Free-text filter over address and country.</summary>
[Reactive]
public partial string SearchText { get; set; }
/// <summary>Protocol filter; <see cref="ProxyProtocolFilter.All"/> means no filtering.</summary>
[Reactive]
public partial ProxyProtocolFilter ProtocolFilter { get; set; }
/// <summary>Health filter.</summary>
[Reactive]
public partial ProxyHealthFilter HealthFilter { get; set; }
/// <summary>Text box contents for adding custom proxies.</summary>
[Reactive]
public partial string NewProxies { get; set; }
/// <summary>Currently selected row.</summary>
[Reactive]
public partial ProxyRowViewModel? SelectedProxy { get; set; }
/// <summary>Outcome of the last action; <see langword="null"/> when idle.</summary>
[Reactive]
public partial string? StatusMessage { get; set; }
/// <summary>Progress of the running sweep, 0..1.</summary>
[Reactive]
public partial double SweepProgress { get; set; }
/// <summary>Whether a sweep is running.</summary>
[Reactive]
public partial bool IsSweeping { get; set; }
/// <summary>How many rows the filter matched before the display cap.</summary>
[Reactive]
public partial int MatchedCount { get; set; }
/// <summary>Total entries in the pool.</summary>
[Reactive]
public partial int TotalCount { get; set; }
/// <summary>How many entries are currently alive.</summary>
[Reactive]
public partial int AliveCount { get; set; }
/// <summary>Creates the page.</summary>
/// <param name="pool">The pool being managed.</param>
/// <param name="customSource">The user's editable list.</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,
ILogger<ProxiesViewModel> logger,
ISequencer? mainThread = null
)
{
_pool = pool ?? throw new ArgumentNullException(nameof(pool));
_customSource = customSource ?? throw new ArgumentNullException(nameof(customSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
SearchText = string.Empty;
NewProxies = string.Empty;
ProtocolFilter = ProxyProtocolFilter.All;
HealthFilter = ProxyHealthFilter.All;
var idle = this.WhenAnyValue(x => x.IsSweeping).Select(static sweeping => !sweeping);
RefreshCommand = ReactiveCommand.CreateFromTask(RefreshAsync, idle, _mainThread);
SweepCommand = ReactiveCommand.CreateFromTask(SweepAsync, idle, _mainThread);
AddCustomCommand = ReactiveCommand.CreateFromTask(AddCustomAsync, idle, _mainThread);
ClearCustomCommand = ReactiveCommand.CreateFromTask(ClearCustomAsync, idle, _mainThread);
RemoveSelectedCommand = ReactiveCommand.CreateFromTask(
RemoveSelectedAsync,
this.WhenAnyValue(x => x.SelectedProxy).Select(static row => row is { IsCustom: true }),
_mainThread
);
// The pool raises Changed on every single lease outcome; rebuilding the view that often
// would make a running parse unusable, so coalesce into one refresh per burst.
_poolChanged
.Throttle(TimeSpan.FromMilliseconds(250), _mainThread)
.ObserveOn(_mainThread)
.Subscribe(_ => Rebuild());
_pool.Changed += OnPoolChanged;
this.WhenAnyValue(x => x.SearchText, x => x.ProtocolFilter, x => x.HealthFilter, (_, _, _) => RxVoid.Default)
.Throttle(TimeSpan.FromMilliseconds(150), _mainThread)
.ObserveOn(_mainThread)
.Subscribe(_ => Rebuild());
foreach (var command in new[] { RefreshCommand, SweepCommand, AddCustomCommand, ClearCustomCommand })
{
command.ThrownExceptions.Subscribe(OnCommandFailed);
}
RemoveSelectedCommand.ThrownExceptions.Subscribe(OnCommandFailed);
Rebuild();
}
/// <inheritdoc />
public override string Title => "Proxies";
/// <inheritdoc />
public override string IconKey => "IconShield";
/// <summary>Rows currently shown, already filtered and capped.</summary>
public ObservableCollection<ProxyRowViewModel> Proxies { get; } = [];
/// <summary>Protocol filter options.</summary>
public IReadOnlyList<ProxyProtocolFilter> ProtocolFilters { get; } =
[
ProxyProtocolFilter.All,
ProxyProtocolFilter.Http,
ProxyProtocolFilter.Https,
ProxyProtocolFilter.Socks4,
ProxyProtocolFilter.Socks5,
];
/// <summary>Health filter options.</summary>
public IReadOnlyList<ProxyHealthFilter> HealthFilters { get; } =
[ProxyHealthFilter.All, ProxyHealthFilter.Alive, ProxyHealthFilter.Dead, ProxyHealthFilter.Unchecked];
/// <summary>Reloads every source into the pool.</summary>
public ReactiveCommand<RxVoid, RxVoid> RefreshCommand { get; }
/// <summary>Probes the whole pool.</summary>
public ReactiveCommand<RxVoid, RxVoid> SweepCommand { get; }
/// <summary>Adds the addresses typed into <see cref="NewProxies"/>.</summary>
public ReactiveCommand<RxVoid, RxVoid> AddCustomCommand { get; }
/// <summary>Removes the selected custom proxy.</summary>
public ReactiveCommand<RxVoid, RxVoid> RemoveSelectedCommand { get; }
/// <summary>Empties the custom list.</summary>
public ReactiveCommand<RxVoid, RxVoid> ClearCustomCommand { get; }
private async Task RefreshAsync(CancellationToken cancellationToken)
{
var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = $"Pool holds {Format(count)} prox{(count == 1 ? "y" : "ies")}.");
}
private async Task SweepAsync(CancellationToken cancellationToken)
{
OnUi(() =>
{
IsSweeping = true;
SweepProgress = 0d;
StatusMessage = null;
});
try
{
var progress = new Progress<ProxySweepProgress>(value => OnUi(() => SweepProgress = value.Fraction));
var alive = await _pool.SweepAsync(progress, cancellationToken).ConfigureAwait(false);
var total = _pool.Entries.Count;
OnUi(() => StatusMessage = $"{Format(alive)} of {Format(total)} answered.");
}
catch (OperationCanceledException)
{
OnUi(() => StatusMessage = "Check cancelled.");
}
finally
{
OnUi(() =>
{
IsSweeping = false;
SweepProgress = 0d;
});
}
}
private async Task AddCustomAsync(CancellationToken cancellationToken)
{
var (parsed, rejected) = CustomProxySource.ParseList(NewProxies);
if (parsed.Count == 0 && rejected.Count == 0)
{
OnUi(() => StatusMessage = "Nothing to add.");
return;
}
var added = await _customSource.AddAsync(parsed, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
var message = $"Added {Format(added)} of {Format(parsed.Count)}.";
if (rejected.Count > 0)
{
// Naming the first few beats "3 lines were invalid" when a paste is hundreds long.
message += $" Could not parse: {string.Join(", ", rejected.Take(3))}";
if (rejected.Count > 3)
{
message += $" and {Format(rejected.Count - 3)} more";
}
}
OnUi(() =>
{
StatusMessage = message;
if (added > 0)
{
NewProxies = string.Empty;
}
});
}
private async Task RemoveSelectedAsync(CancellationToken cancellationToken)
{
if (SelectedProxy is not { IsCustom: true } row)
{
return;
}
await _customSource.RemoveAsync(row.Entry.Endpoint, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = $"Removed {row.Address}.");
}
private async Task ClearCustomAsync(CancellationToken cancellationToken)
{
await _customSource.ClearAsync(cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = "Custom list cleared.");
}
/// <inheritdoc />
/// <remarks>
/// Detaching from the pool matters: the pool is a singleton and would otherwise keep this
/// page — and every row in it — alive for the process lifetime.
/// </remarks>
public void Dispose()
{
_pool.Changed -= OnPoolChanged;
_poolChanged.Dispose();
GC.SuppressFinalize(this);
}
private void OnPoolChanged(object? sender, EventArgs e) => _poolChanged.OnNext(RxVoid.Default);
private void Rebuild()
{
var entries = _pool.Entries;
// Reconcile rows rather than recreating them: a row rebuilt on every pool event would
// drop the user's selection mid-sweep.
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in entries)
{
var key = entry.Endpoint.Key;
seen.Add(key);
if (_rows.TryGetValue(key, out var existing))
{
existing.Refresh();
}
else
{
_rows[key] = new ProxyRowViewModel(entry);
}
}
foreach (var stale in _rows.Keys.Where(key => !seen.Contains(key)).ToArray())
{
_rows.Remove(stale);
}
var matched = _rows.Values.Where(PassesFilters).OrderBy(row => row.Address, StringComparer.Ordinal).ToArray();
Proxies.Clear();
foreach (var row in matched.Take(MaxDisplayedRows))
{
Proxies.Add(row);
}
MatchedCount = matched.Length;
TotalCount = entries.Count;
AliveCount = entries.Count(entry => entry.Health == ProxyHealthState.Alive);
}
private bool PassesFilters(ProxyRowViewModel row)
{
if (
ProtocolFilter != ProxyProtocolFilter.All
&& !ProtocolFilter.HasFlag(ProxyOptions.ToFlag(row.Entry.Endpoint.Protocol))
)
{
return false;
}
var healthOk = HealthFilter switch
{
ProxyHealthFilter.Alive => row.Entry.Health == ProxyHealthState.Alive,
ProxyHealthFilter.Dead => row.Entry.Health == ProxyHealthState.Dead,
ProxyHealthFilter.Unchecked => row.Entry.Health == ProxyHealthState.Unknown,
_ => true,
};
return healthOk && row.Matches(SearchText);
}
private void OnCommandFailed(Exception exception)
{
_logger.LogError(exception, "Proxy action failed");
OnUi(() => StatusMessage = $"Failed: {exception.Message}");
}
private static string Format(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
private void OnUi(Action action) => _mainThread.Schedule(action);
}
@@ -0,0 +1,88 @@
using System.Globalization;
using AvParser.Core.Proxies;
using ReactiveUI;
namespace AvParser.UI.ViewModels;
/// <summary>One row of the proxy list.</summary>
/// <remarks>
/// A thin view over <see cref="ProxyEntry"/> rather than a copy of it: the pool mutates entries
/// on every request, and mirroring their fields would mean reconciling two sources of truth.
/// The pool has no change notification per entry, so the page calls <see cref="Refresh"/> after
/// a pool-level change instead.
/// </remarks>
public sealed class ProxyRowViewModel(ProxyEntry entry) : ReactiveObject
{
/// <summary>The pool entry behind this row.</summary>
public ProxyEntry Entry { get; } = entry ?? throw new ArgumentNullException(nameof(entry));
/// <summary>Stable key, used to reconcile rows against the pool.</summary>
public string Key => Entry.Endpoint.Key;
/// <summary>Address as <c>scheme://host:port</c>.</summary>
public string Address => Entry.Endpoint.ToString();
/// <summary>Protocol name for the list.</summary>
public string Protocol => Entry.Endpoint.Protocol.ToString().ToUpperInvariant();
/// <summary>Country code, or an em dash when unknown.</summary>
public string Country => Entry.Endpoint.Country ?? "—";
/// <summary>Anonymity level.</summary>
public string Anonymity => Entry.Endpoint.Anonymity.ToString();
/// <summary>Whether the proxy came from the user's own list.</summary>
public bool IsCustom => Entry.Source == ProxySourceKind.Custom;
/// <summary>Source label.</summary>
public string Source => IsCustom ? "custom" : "feed";
/// <summary>Last known health, as a word.</summary>
public string HealthText =>
Entry.Health switch
{
ProxyHealthState.Alive => "alive",
ProxyHealthState.Dead => "dead",
_ => "unchecked",
};
/// <summary>Whether the last check succeeded. Drives the row's accent.</summary>
public bool IsAlive => Entry.Health == ProxyHealthState.Alive;
/// <summary>Whether the last check failed.</summary>
public bool IsDead => Entry.Health == ProxyHealthState.Dead;
/// <summary>Latency of the last successful check, or an em dash.</summary>
public string LatencyText =>
Entry.Latency is { } latency
? $"{latency.TotalMilliseconds.ToString("N0", CultureInfo.CurrentCulture)} ms"
: "—";
/// <summary>Successes and failures observed so far.</summary>
public string ScoreText =>
$"{Entry.SuccessCount.ToString(CultureInfo.CurrentCulture)}/{(Entry.SuccessCount + Entry.FailureCount).ToString(CultureInfo.CurrentCulture)}";
/// <summary>Reason recorded with the last failure.</summary>
public string? LastError => Entry.LastError;
/// <summary>Whether the entry is sidelined right now.</summary>
public bool IsQuarantined => Entry.QuarantinedUntilUtc is { } until && until > DateTimeOffset.UtcNow;
/// <summary>Re-reads everything that the pool can change behind our back.</summary>
public void Refresh()
{
this.RaisePropertyChanged(nameof(HealthText));
this.RaisePropertyChanged(nameof(IsAlive));
this.RaisePropertyChanged(nameof(IsDead));
this.RaisePropertyChanged(nameof(LatencyText));
this.RaisePropertyChanged(nameof(ScoreText));
this.RaisePropertyChanged(nameof(LastError));
this.RaisePropertyChanged(nameof(IsQuarantined));
}
/// <summary>Whether the row matches a free-text query.</summary>
public bool Matches(string? query) =>
string.IsNullOrWhiteSpace(query)
|| Address.Contains(query, StringComparison.OrdinalIgnoreCase)
|| Country.Contains(query, StringComparison.OrdinalIgnoreCase);
}
@@ -1,3 +1,4 @@
using AvParser.Core.Proxies;
using AvParser.Core.Settings; using AvParser.Core.Settings;
using AvParser.Infrastructure.Logging; using AvParser.Infrastructure.Logging;
using AvParser.Infrastructure.Storage; using AvParser.Infrastructure.Storage;
@@ -17,6 +18,7 @@ public partial class SettingsViewModel : PageViewModel
private readonly ISettingsService _settings; private readonly ISettingsService _settings;
private readonly IThemeService _theme; private readonly IThemeService _theme;
private readonly LoggingLevelSwitch _levelSwitch; private readonly LoggingLevelSwitch _levelSwitch;
private readonly IProxyPool _proxyPool;
/// <summary>Selected theme. Applied immediately, not on an OK button.</summary> /// <summary>Selected theme. Applied immediately, not on an OK button.</summary>
[Reactive] [Reactive]
@@ -26,18 +28,44 @@ public partial class SettingsViewModel : PageViewModel
[Reactive] [Reactive]
public partial string SelectedLogLevel { get; set; } public partial string SelectedLogLevel { get; set; }
/// <summary>How the pool picks the next proxy.</summary>
[Reactive]
public partial ProxyRotation SelectedRotation { get; set; }
/// <summary>When proxy liveness is verified.</summary>
[Reactive]
public partial ProxyHealthCheck SelectedHealthCheck { get; set; }
/// <summary>Whether the remote proxy feed is consulted.</summary>
[Reactive]
public partial bool UseProxyFeed { get; set; }
/// <summary>URL fetched to decide whether a proxy works.</summary>
[Reactive]
public partial string ProxyProbeUrl { get; set; }
/// <summary>Per-proxy probe timeout, in seconds.</summary>
[Reactive]
public partial int ProxyProbeTimeoutSeconds { get; set; }
/// <summary>How many probes run at once during a sweep.</summary>
[Reactive]
public partial int ProxyProbeConcurrency { get; set; }
/// <summary>Creates the page.</summary> /// <summary>Creates the page.</summary>
public SettingsViewModel( public SettingsViewModel(
ISettingsService settings, ISettingsService settings,
IThemeService theme, IThemeService theme,
IAppPaths paths, IAppPaths paths,
LoggingLevelSwitch levelSwitch, LoggingLevelSwitch levelSwitch,
IProxyPool proxyPool,
ISequencer? mainThread = null ISequencer? mainThread = null
) )
{ {
_settings = settings ?? throw new ArgumentNullException(nameof(settings)); _settings = settings ?? throw new ArgumentNullException(nameof(settings));
_theme = theme ?? throw new ArgumentNullException(nameof(theme)); _theme = theme ?? throw new ArgumentNullException(nameof(theme));
_levelSwitch = levelSwitch ?? throw new ArgumentNullException(nameof(levelSwitch)); _levelSwitch = levelSwitch ?? throw new ArgumentNullException(nameof(levelSwitch));
_proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool));
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler; var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler;
@@ -48,6 +76,14 @@ public partial class SettingsViewModel : PageViewModel
SelectedTheme = theme.Current; SelectedTheme = theme.Current;
SelectedLogLevel = settings.Current.MinimumLogLevel; SelectedLogLevel = settings.Current.MinimumLogLevel;
var current = settings.Current;
SelectedRotation = current.ProxyRotation;
SelectedHealthCheck = current.ProxyHealthCheck;
UseProxyFeed = current.ProxyUseFeed;
ProxyProbeUrl = current.ProxyProbeUrl;
ProxyProbeTimeoutSeconds = current.ProxyProbeTimeoutSeconds;
ProxyProbeConcurrency = current.ProxyProbeConcurrency;
this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(_theme.Apply); this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(_theme.Apply);
this.WhenAnyValue(x => x.SelectedLogLevel) this.WhenAnyValue(x => x.SelectedLogLevel)
@@ -57,6 +93,27 @@ public partial class SettingsViewModel : PageViewModel
// Keep the radio group honest when the theme is flipped from the title-bar button. // Keep the radio group honest when the theme is flipped from the title-bar button.
theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = value); theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = value);
// Every proxy knob funnels through one handler: they all end up rebuilding the same
// ProxyOptions, and applying them one at a time would reconfigure the pool six times.
this.WhenAnyValue(
x => x.SelectedRotation,
x => x.SelectedHealthCheck,
x => x.UseProxyFeed,
x => x.ProxyProbeUrl,
x => x.ProxyProbeTimeoutSeconds,
x => x.ProxyProbeConcurrency,
(_, _, _, _, _, _) => RxVoid.Default
)
.Throttle(TimeSpan.FromMilliseconds(200), scheduler)
.ObserveOn(scheduler)
.Subscribe(_ => ApplyProxySettings());
this.WhenAnyValue(x => x.SelectedRotation)
.Subscribe(_ => this.RaisePropertyChanged(nameof(RotationDescription)));
this.WhenAnyValue(x => x.SelectedHealthCheck)
.Subscribe(_ => this.RaisePropertyChanged(nameof(HealthCheckDescription)));
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -71,6 +128,29 @@ public partial class SettingsViewModel : PageViewModel
/// <summary>Serilog level names, most to least verbose.</summary> /// <summary>Serilog level names, most to least verbose.</summary>
public IReadOnlyList<string> LogLevels => AppLogging.AvailableLevels; public IReadOnlyList<string> LogLevels => AppLogging.AvailableLevels;
/// <summary>Rotation strategies offered by the picker.</summary>
public IReadOnlyList<ProxyRotation> Rotations { get; } =
[ProxyRotation.Sticky, ProxyRotation.RoundRobin, ProxyRotation.WeightedRandom];
/// <summary>Liveness policies offered by the picker.</summary>
public IReadOnlyList<ProxyHealthCheck> HealthChecks { get; } = [ProxyHealthCheck.Pool, ProxyHealthCheck.Lazy];
/// <summary>Explains the selected rotation in one line.</summary>
public string RotationDescription =>
SelectedRotation switch
{
ProxyRotation.Sticky => "One proxy per session, replaced only when it fails. Keeps site sessions intact.",
ProxyRotation.RoundRobin =>
"A different proxy on every request. Spreads rate limits, but breaks session cookies.",
_ => "Random, weighted by feed score and how often the proxy has actually worked here.",
};
/// <summary>Explains the selected health-check policy in one line.</summary>
public string HealthCheckDescription =>
SelectedHealthCheck == ProxyHealthCheck.Pool
? "Probe the whole pool up front, in parallel. One sweep, then no per-request delay."
: "Probe each proxy as it is handed out. No sweep, but every acquisition pays a round trip.";
/// <summary>Full path of the settings file.</summary> /// <summary>Full path of the settings file.</summary>
public string SettingsFile { get; } public string SettingsFile { get; }
@@ -88,4 +168,28 @@ public partial class SettingsViewModel : PageViewModel
_levelSwitch.MinimumLevel = AppLogging.ParseLevel(level); _levelSwitch.MinimumLevel = AppLogging.ParseLevel(level);
_settings.Update(current => current with { MinimumLogLevel = level }); _settings.Update(current => current with { MinimumLogLevel = level });
} }
private void ApplyProxySettings()
{
AppSettings? applied = null;
_settings.Update(current =>
{
applied = current with
{
ProxyRotation = SelectedRotation,
ProxyHealthCheck = SelectedHealthCheck,
ProxyUseFeed = UseProxyFeed,
ProxyProbeUrl = ProxyProbeUrl,
ProxyProbeTimeoutSeconds = ProxyProbeTimeoutSeconds,
ProxyProbeConcurrency = ProxyProbeConcurrency,
};
return applied;
});
// Update() short-circuits a no-op change, so fall back to the stored value: the pool must
// still be configured on the very first pass, when nothing has changed yet.
_proxyPool.Configure((applied ?? _settings.Current).ToProxyOptions());
}
} }
+1
View File
@@ -99,6 +99,7 @@
<TextBox <TextBox
Text="{Binding InputText}" Text="{Binding InputText}"
AcceptsReturn="True" AcceptsReturn="True"
VerticalContentAlignment="Top"
AcceptsTab="True" AcceptsTab="True"
TextWrapping="NoWrap" TextWrapping="NoWrap"
PlaceholderText="Paste text here, or press Sample" PlaceholderText="Paste text here, or press Sample"
+221
View File
@@ -0,0 +1,221 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
xmlns:conv="clr-namespace:AvParser.UI.Converters"
x:Class="AvParser.UI.Views.ProxiesView"
x:DataType="vm:ProxiesViewModel"
>
<Grid RowDefinitions="Auto,Auto,*">
<!-- ===== Toolbar ===== -->
<Border Grid.Row="0" Classes="card" Margin="0,0,0,12">
<StackPanel Spacing="12">
<WrapPanel Orientation="Horizontal">
<StackPanel Spacing="4" Margin="0,0,16,8">
<TextBlock Classes="caption" Text="POOL" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="primary" Command="{Binding RefreshCommand}" ToolTip.Tip="Reload every source">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconRefresh}" />
<TextBlock Text="Refresh" />
</StackPanel>
</Button>
<Button Command="{Binding SweepCommand}" ToolTip.Tip="Probe every proxy in the pool">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconShield}" />
<TextBlock Text="Check all" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="220">
<TextBlock Classes="caption" Text="SEARCH" />
<TextBox Text="{Binding SearchText}" PlaceholderText="address or country" />
</StackPanel>
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="130">
<TextBlock Classes="caption" Text="PROTOCOL" />
<ComboBox
ItemsSource="{Binding ProtocolFilters}"
SelectedItem="{Binding ProtocolFilter}"
HorizontalAlignment="Stretch"
/>
</StackPanel>
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="130">
<TextBlock Classes="caption" Text="STATUS" />
<ComboBox
ItemsSource="{Binding HealthFilters}"
SelectedItem="{Binding HealthFilter}"
HorizontalAlignment="Stretch"
/>
</StackPanel>
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Classes="chip accent">
<TextBlock Classes="mono caption">
<Run Text="{Binding AliveCount}" />
<Run Text="alive" />
</TextBlock>
</Border>
<Border Classes="chip">
<TextBlock Classes="mono caption">
<Run Text="{Binding TotalCount}" />
<Run Text="total" />
</TextBlock>
</Border>
<Border Classes="chip">
<TextBlock Classes="mono caption">
<Run Text="{Binding MatchedCount}" />
<Run Text="matched" />
</TextBlock>
</Border>
</StackPanel>
</StackPanel>
</Border>
<!-- ===== Progress and status ===== -->
<StackPanel Grid.Row="1" Spacing="8" Margin="0,0,0,12">
<ProgressBar
Minimum="0"
Maximum="1"
Value="{Binding SweepProgress}"
IsVisible="{Binding IsSweeping}"
Height="4"
/>
<TextBlock
Classes="muted"
Text="{Binding StatusMessage}"
IsVisible="{Binding StatusMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
/>
</StackPanel>
<!-- ===== List and custom entry ===== -->
<Grid Grid.Row="2" ColumnDefinitions="2*,8,*">
<Border Grid.Column="0" Classes="card" Padding="0">
<DockPanel LastChildFill="True">
<Border
DockPanel.Dock="Top"
Padding="16,12"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}"
>
<TextBlock Classes="caption" Text="PROXIES" />
</Border>
<ListBox
ItemsSource="{Binding Proxies}"
SelectedItem="{Binding SelectedProxy}"
Background="Transparent"
BorderThickness="0"
SelectionMode="Single"
>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ProxyRowViewModel">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
<Border
Grid.Column="0"
Classes="chip"
Classes.ok="{Binding IsAlive}"
Classes.bad="{Binding IsDead}"
VerticalAlignment="Center"
MinWidth="70"
>
<TextBlock Classes="mono caption" Text="{Binding HealthText}" HorizontalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding Address}" />
<TextBlock
Classes="caption"
Text="{Binding LastError}"
IsVisible="{Binding LastError, Converter={x:Static ObjectConverters.IsNotNull}}"
Foreground="{DynamicResource AppDangerBrush}"
/>
</StackPanel>
<TextBlock
Grid.Column="2"
Classes="caption"
Text="{Binding Country}"
VerticalAlignment="Center"
MinWidth="30"
/>
<TextBlock
Grid.Column="3"
Classes="mono caption"
Text="{Binding LatencyText}"
VerticalAlignment="Center"
MinWidth="60"
TextAlignment="Right"
/>
<Border Grid.Column="4" Classes="chip" VerticalAlignment="Center">
<TextBlock Classes="caption" Text="{Binding Source}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="Transparent" />
<Border Grid.Column="2" Classes="card" Padding="0">
<DockPanel LastChildFill="True">
<Border
DockPanel.Dock="Top"
Padding="16,12"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}"
>
<TextBlock Classes="caption" Text="CUSTOM PROXIES" />
</Border>
<StackPanel DockPanel.Dock="Bottom" Spacing="8" Margin="16,12">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="primary" Command="{Binding AddCustomCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconPlus}" />
<TextBlock Text="Add" />
</StackPanel>
</Button>
<Button
Classes="destructive"
Command="{Binding RemoveSelectedCommand}"
ToolTip.Tip="Remove the selected custom proxy"
>
<TextBlock Text="Remove" />
</Button>
<Button Classes="icon" Command="{Binding ClearCustomCommand}" ToolTip.Tip="Clear the custom list">
<PathIcon Classes="glyph" Data="{DynamicResource IconTrash}" />
</Button>
</StackPanel>
<TextBlock
Classes="muted"
Text="One per line. scheme://host:port, or host:port for plain HTTP. user:pass@ is supported."
/>
</StackPanel>
<TextBox
Text="{Binding NewProxies}"
AcceptsReturn="True"
VerticalContentAlignment="Top"
TextWrapping="NoWrap"
PlaceholderText="socks5://10.0.0.1:1080&#x0a;user:pass@10.0.0.2:8080"
BorderThickness="0"
Background="Transparent"
Margin="4,0"
FontFamily="Cascadia Code,Consolas,Menlo,DejaVu Sans Mono,monospace"
FontSize="{DynamicResource FontSizeBody}"
ScrollViewer.VerticalScrollBarVisibility="Auto"
/>
</DockPanel>
</Border>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,13 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace AvParser.UI.Views;
/// <summary>Proxy pool management: feed refresh, probing and the custom list.</summary>
public partial class ProxiesView : UserControl
{
/// <summary>Creates the view.</summary>
public ProxiesView() => InitializeComponent();
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
}
+60
View File
@@ -51,6 +51,66 @@
</StackPanel> </StackPanel>
</Border> </Border>
<Border Classes="card">
<StackPanel Spacing="16">
<TextBlock Classes="subtitle" Text="Proxies" />
<StackPanel Spacing="6">
<TextBlock Classes="caption" Text="ROTATION" />
<ComboBox
ItemsSource="{Binding Rotations}"
SelectedItem="{Binding SelectedRotation}"
HorizontalAlignment="Stretch"
/>
<TextBlock Classes="muted" Text="{Binding RotationDescription}" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="caption" Text="LIVENESS CHECK" />
<ComboBox
ItemsSource="{Binding HealthChecks}"
SelectedItem="{Binding SelectedHealthCheck}"
HorizontalAlignment="Stretch"
/>
<TextBlock Classes="muted" Text="{Binding HealthCheckDescription}" />
</StackPanel>
<CheckBox IsChecked="{Binding UseProxyFeed}" Content="Use the public proxifly feed" />
<StackPanel Spacing="6">
<TextBlock Classes="caption" Text="PROBE URL" />
<TextBox Text="{Binding ProxyProbeUrl}" />
<TextBlock
Classes="muted"
Text="Plain HTTP by default: requiring TLS would fail every proxy that cannot do CONNECT, not just the dead ones."
/>
</StackPanel>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Classes="caption" Text="PROBE TIMEOUT (SEC)" />
<NumericUpDown
Value="{Binding ProxyProbeTimeoutSeconds}"
Minimum="1"
Maximum="120"
Increment="1"
FormatString="0"
/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Classes="caption" Text="PARALLEL PROBES" />
<NumericUpDown
Value="{Binding ProxyProbeConcurrency}"
Minimum="1"
Maximum="512"
Increment="8"
FormatString="0"
/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<Border Classes="card"> <Border Classes="card">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="subtitle" Text="Layout breakpoints" /> <TextBlock Classes="subtitle" Text="Layout breakpoints" />
@@ -0,0 +1,106 @@
using AvParser.Core.Proxies;
namespace AvParser.Core.Tests.Proxies;
public class ProxyEndpointTests
{
[Theory]
[InlineData("1.2.3.4:8080", ProxyProtocol.Http, "1.2.3.4", 8080)]
[InlineData("http://1.2.3.4:8080", ProxyProtocol.Http, "1.2.3.4", 8080)]
[InlineData("https://proxy.example.com:3128", ProxyProtocol.Https, "proxy.example.com", 3128)]
[InlineData("socks4://1.2.3.4:1080", ProxyProtocol.Socks4, "1.2.3.4", 1080)]
[InlineData("socks5://1.2.3.4:1080", ProxyProtocol.Socks5, "1.2.3.4", 1080)]
[InlineData(" socks5h://1.2.3.4:1080 ", ProxyProtocol.Socks5, "1.2.3.4", 1080)]
public void Parses_the_forms_public_lists_actually_use(string text, ProxyProtocol protocol, string host, int port)
{
ProxyEndpoint.TryParse(text, out var endpoint).ShouldBeTrue();
endpoint!.Protocol.ShouldBe(protocol);
endpoint.Host.ShouldBe(host);
endpoint.Port.ShouldBe(port);
}
[Fact]
public void Parses_credentials()
{
ProxyEndpoint.TryParse("http://alice:s3cret@1.2.3.4:8080", out var endpoint).ShouldBeTrue();
endpoint!.Username.ShouldBe("alice");
endpoint.Password.ShouldBe("s3cret");
endpoint.HasCredentials.ShouldBeTrue();
}
[Fact]
public void Takes_the_last_at_sign_so_a_password_may_contain_one()
{
ProxyEndpoint.TryParse("alice:p@ss@1.2.3.4:8080", out var endpoint).ShouldBeTrue();
endpoint!.Username.ShouldBe("alice");
endpoint.Password.ShouldBe("p@ss");
endpoint.Host.ShouldBe("1.2.3.4");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("1.2.3.4")]
[InlineData("1.2.3.4:")]
[InlineData("1.2.3.4:0")]
[InlineData("1.2.3.4:70000")]
[InlineData("1.2.3.4:notaport")]
[InlineData(":8080")]
[InlineData("gopher://1.2.3.4:8080")]
[InlineData("@1.2.3.4:8080")]
public void Rejects_malformed_input(string? text) => ProxyEndpoint.TryParse(text, out _).ShouldBeFalse();
[Fact]
public void Key_is_case_insensitive_and_identifies_the_address()
{
ProxyEndpoint.TryParse("SOCKS5://Proxy.Example.COM:1080", out var upper).ShouldBeTrue();
ProxyEndpoint.TryParse("socks5://proxy.example.com:1080", out var lower).ShouldBeTrue();
upper!.Key.ShouldBe(lower!.Key);
}
[Fact]
public void Key_separates_the_same_host_on_different_protocols()
{
ProxyEndpoint.TryParse("http://1.2.3.4:1080", out var http).ShouldBeTrue();
ProxyEndpoint.TryParse("socks5://1.2.3.4:1080", out var socks).ShouldBeTrue();
http!.Key.ShouldNotBe(socks!.Key);
}
[Theory]
[InlineData(ProxyProtocol.Http, "http")]
[InlineData(ProxyProtocol.Https, "http")]
[InlineData(ProxyProtocol.Socks4, "socks4")]
[InlineData(ProxyProtocol.Socks5, "socks5")]
public void Https_proxies_are_still_reached_over_the_http_scheme(ProxyProtocol protocol, string scheme)
{
// .NET has no "https" proxy scheme — an HTTPS-capable proxy tunnels TLS with CONNECT
// over a plain http:// proxy URI. Getting this wrong makes every such proxy unusable.
new ProxyEndpoint(protocol, "1.2.3.4", 8080).Scheme.ShouldBe(scheme);
}
[Fact]
public void Round_trips_through_its_own_string_form()
{
var original = new ProxyEndpoint(ProxyProtocol.Socks5, "1.2.3.4", 1080);
ProxyEndpoint.TryParse(original.ToString(), out var parsed).ShouldBeTrue();
parsed!.Key.ShouldBe(original.Key);
}
[Theory]
[InlineData("transparent", ProxyAnonymity.Transparent)]
[InlineData("anonymous", ProxyAnonymity.Anonymous)]
[InlineData("elite", ProxyAnonymity.Elite)]
[InlineData("high", ProxyAnonymity.Elite)]
[InlineData("nonsense", ProxyAnonymity.Unknown)]
[InlineData(null, ProxyAnonymity.Unknown)]
public void Parses_anonymity(string? text, ProxyAnonymity expected) =>
ProxyEndpoint.ParseAnonymity(text).ShouldBe(expected);
}
@@ -0,0 +1,370 @@
using AvParser.Core.Proxies;
namespace AvParser.Core.Tests.Proxies;
public class ProxyPoolTests
{
private static readonly ProxyOptions PoolMode = new()
{
HealthCheck = ProxyHealthCheck.Pool,
Rotation = ProxyRotation.Sticky,
};
private static ProxyPool Build(
out FakeProxySource source,
out FakeProxyProbe probe,
out FakeTimeProvider clock,
ProxyOptions? options = null,
params string[] hosts
)
{
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 ?? PoolMode, clock);
}
[Fact]
public async Task Refresh_loads_every_source()
{
var pool = Build(out _, out _, out _, hosts: ["a", "b", "c"]);
var count = await pool.RefreshAsync(TestContext.Current.CancellationToken);
count.ShouldBe(3);
pool.Entries.Count.ShouldBe(3);
}
[Fact]
public async Task Refresh_drops_duplicates_across_sources()
{
var feed = new FakeProxySource(ProxySourceKind.Feed);
feed.Endpoints.Add(ProxyFactory.Endpoint("shared"));
var custom = new FakeProxySource(ProxySourceKind.Custom) { Id = "custom" };
custom.Endpoints.Add(ProxyFactory.Endpoint("shared"));
var pool = new ProxyPool([feed, custom], new FakeProxyProbe(), PoolMode, new FakeTimeProvider());
(await pool.RefreshAsync(TestContext.Current.CancellationToken)).ShouldBe(1);
}
[Fact]
public async Task Refresh_keeps_what_the_pool_already_learned()
{
var pool = Build(out _, out _, out var clock, hosts: ["a"]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
pool.Entries[0].RecordSuccess(clock.GetUtcNow(), TimeSpan.FromMilliseconds(120));
// Free lists are republished every few minutes; a reload that reset every counter would
// throw away the only real evidence the app has.
await pool.RefreshAsync(TestContext.Current.CancellationToken);
pool.Entries[0].SuccessCount.ShouldBe(1);
pool.Entries[0].Latency!.Value.TotalMilliseconds.ShouldBe(120);
}
[Fact]
public async Task Refresh_forgets_addresses_that_left_the_feed()
{
var pool = Build(out var source, out _, out _, hosts: ["a", "b"]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
source.Endpoints.RemoveAll(endpoint => endpoint.Host == "b");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
pool.Entries.Select(entry => entry.Endpoint.Host).ShouldBe(["a"]);
}
[Fact]
public async Task Refresh_applies_the_protocol_filter()
{
var source = new FakeProxySource(ProxySourceKind.Feed);
source.Endpoints.Add(ProxyFactory.Endpoint("http", protocol: ProxyProtocol.Http));
source.Endpoints.Add(ProxyFactory.Endpoint("socks", protocol: ProxyProtocol.Socks5));
var options = PoolMode with { Protocols = ProxyProtocolFilter.Socks5 };
var pool = new ProxyPool([source], new FakeProxyProbe(), options, new FakeTimeProvider());
await pool.RefreshAsync(TestContext.Current.CancellationToken);
pool.Entries.ShouldHaveSingleItem().Endpoint.Protocol.ShouldBe(ProxyProtocol.Socks5);
}
[Fact]
public async Task Refresh_skips_the_feed_when_it_is_switched_off()
{
var pool = Build(out var source, out _, out _, PoolMode with { UseFeed = false }, "a");
(await pool.RefreshAsync(TestContext.Current.CancellationToken)).ShouldBe(0);
source.GetCallCount.ShouldBe(0);
}
[Fact]
public async Task Acquire_returns_null_for_an_empty_pool() =>
(await Build(out _, out _, out _).AcquireAsync(TestContext.Current.CancellationToken)).ShouldBeNull();
[Fact]
public async Task Pool_mode_hands_out_without_probing()
{
var pool = Build(out _, out var probe, out _, hosts: ["a"]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
lease.ShouldNotBeNull();
probe.ProbeCount.ShouldBe(0);
}
[Fact]
public async Task Lazy_mode_skips_past_dead_proxies()
{
var options = PoolMode with { HealthCheck = ProxyHealthCheck.Lazy, Rotation = ProxyRotation.RoundRobin };
var pool = Build(out _, out var probe, out _, options, "dead1", "dead2", "alive");
probe.Set(ProxyFactory.Endpoint("alive"), alive: true);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
lease.ShouldNotBeNull();
lease.Endpoint.Host.ShouldBe("alive");
}
[Fact]
public async Task Lazy_mode_gives_up_after_the_configured_number_of_attempts()
{
var options = PoolMode with
{
HealthCheck = ProxyHealthCheck.Lazy,
Rotation = ProxyRotation.RoundRobin,
LazyProbeAttempts = 2,
};
var pool = Build(out _, out var probe, out _, options, "a", "b", "c", "d", "e");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
(await pool.AcquireAsync(TestContext.Current.CancellationToken)).ShouldBeNull();
probe.ProbeCount.ShouldBe(2);
}
[Fact]
public async Task Lazy_mode_trusts_a_proxy_already_known_to_be_alive()
{
var options = PoolMode with { HealthCheck = ProxyHealthCheck.Lazy };
var pool = Build(out _, out var probe, out var clock, options, "a");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
pool.Entries[0].RecordProbe(clock.GetUtcNow(), alive: true, TimeSpan.FromMilliseconds(10), null);
await pool.AcquireAsync(TestContext.Current.CancellationToken);
probe.ProbeCount.ShouldBe(0);
}
[Fact]
public async Task A_failing_proxy_is_quarantined_and_comes_back_later()
{
var options = PoolMode with
{
FailuresBeforeQuarantine = 1,
BaseQuarantine = TimeSpan.FromSeconds(30),
MaxQuarantine = TimeSpan.FromMinutes(15),
};
var pool = Build(out _, out _, out var clock, options, "a");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
lease.ShouldNotBeNull();
lease.ReportFailure("boom");
// Sidelined immediately...
(await pool.AcquireAsync(TestContext.Current.CancellationToken)).ShouldBeNull();
clock.Advance(TimeSpan.FromSeconds(31));
// ...and available again once the window expires.
(await pool.AcquireAsync(TestContext.Current.CancellationToken)).ShouldNotBeNull();
}
[Fact]
public async Task The_quarantine_window_grows_with_repeated_failures()
{
var options = PoolMode with
{
FailuresBeforeQuarantine = 1,
BaseQuarantine = TimeSpan.FromSeconds(10),
MaxQuarantine = TimeSpan.FromHours(1),
};
var pool = Build(out _, out _, out var clock, options, "a");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var entry = pool.Entries[0];
entry.RecordFailure(clock.GetUtcNow(), options.BaseQuarantine, options.MaxQuarantine, 1);
var first = entry.QuarantinedUntilUtc!.Value - clock.GetUtcNow();
entry.RecordFailure(clock.GetUtcNow(), options.BaseQuarantine, options.MaxQuarantine, 1);
var second = entry.QuarantinedUntilUtc!.Value - clock.GetUtcNow();
second.ShouldBeGreaterThan(first);
}
[Fact]
public async Task The_quarantine_window_is_capped()
{
var pool = Build(out _, out _, out var clock, hosts: ["a"]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var entry = pool.Entries[0];
for (var i = 0; i < 40; i++)
{
entry.RecordFailure(clock.GetUtcNow(), TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(5), 1);
}
(entry.QuarantinedUntilUtc!.Value - clock.GetUtcNow()).ShouldBeLessThanOrEqualTo(TimeSpan.FromMinutes(5));
}
[Fact]
public async Task A_success_clears_the_quarantine()
{
var pool = Build(out _, out _, out var clock, PoolMode with { FailuresBeforeQuarantine = 1 }, "a");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var first = await pool.AcquireAsync(TestContext.Current.CancellationToken);
first!.ReportFailure();
clock.Advance(TimeSpan.FromMinutes(1));
var second = await pool.AcquireAsync(TestContext.Current.CancellationToken);
second!.ReportSuccess(TimeSpan.FromMilliseconds(50));
pool.Entries[0].QuarantinedUntilUtc.ShouldBeNull();
pool.Entries[0].ConsecutiveFailures.ShouldBe(0);
}
[Fact]
public async Task Disposing_a_lease_without_a_verdict_says_nothing_about_the_proxy()
{
var pool = Build(out _, out _, out _, hosts: ["a"]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
using (await pool.AcquireAsync(TestContext.Current.CancellationToken))
{
// A cancelled operation is not the proxy's fault.
}
pool.Entries[0].FailureCount.ShouldBe(0);
pool.Entries[0].SuccessCount.ShouldBe(0);
}
[Fact]
public async Task A_lease_reports_only_once()
{
var pool = Build(out _, out _, out _, hosts: ["a"]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
lease!.ReportSuccess();
lease.ReportFailure("late");
pool.Entries[0].SuccessCount.ShouldBe(1);
pool.Entries[0].FailureCount.ShouldBe(0);
}
[Fact]
public async Task Sweep_probes_everything_and_counts_the_survivors()
{
var pool = Build(out _, out var probe, out _, hosts: ["a", "b", "c"]);
probe.Set(ProxyFactory.Endpoint("a"), alive: true);
probe.Set(ProxyFactory.Endpoint("c"), alive: true);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var reports = new List<ProxySweepProgress>();
var alive = await pool.SweepAsync(
new SynchronousProgress<ProxySweepProgress>(reports.Add),
TestContext.Current.CancellationToken
);
alive.ShouldBe(2);
probe.ProbeCount.ShouldBe(3);
reports.Count.ShouldBe(3);
reports[^1].Fraction.ShouldBe(1d);
}
[Fact]
public async Task Sweep_on_an_empty_pool_reports_completion_rather_than_hanging()
{
var pool = Build(out _, out _, out _);
var reports = new List<ProxySweepProgress>();
var alive = await pool.SweepAsync(
new SynchronousProgress<ProxySweepProgress>(reports.Add),
TestContext.Current.CancellationToken
);
alive.ShouldBe(0);
reports.ShouldHaveSingleItem().Total.ShouldBe(0);
}
[Fact]
public async Task Changing_the_rotation_strategy_takes_effect()
{
var pool = Build(out _, out _, out _, hosts: ["a", "b", "c"]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var sticky = await pool.AcquireAsync(TestContext.Current.CancellationToken);
var stickyAgain = await pool.AcquireAsync(TestContext.Current.CancellationToken);
stickyAgain!.Endpoint.Key.ShouldBe(sticky!.Endpoint.Key);
pool.Configure(PoolMode with { Rotation = ProxyRotation.RoundRobin });
var first = await pool.AcquireAsync(TestContext.Current.CancellationToken);
var second = await pool.AcquireAsync(TestContext.Current.CancellationToken);
second!.Endpoint.Key.ShouldNotBe(first!.Endpoint.Key);
}
[Fact]
public async Task Selection_prefers_proxies_known_to_be_alive()
{
var pool = Build(out _, out _, out var clock, hosts: ["slow", "fast"]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
var slow = pool.Entries.Single(entry => entry.Endpoint.Host == "slow");
var fast = pool.Entries.Single(entry => entry.Endpoint.Host == "fast");
slow.RecordProbe(clock.GetUtcNow(), alive: true, TimeSpan.FromMilliseconds(900), null);
fast.RecordProbe(clock.GetUtcNow(), alive: true, TimeSpan.FromMilliseconds(30), null);
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
lease!.Endpoint.Host.ShouldBe("fast");
}
[Fact]
public async Task Changed_fires_when_the_pool_moves()
{
var pool = Build(out _, out _, out _, hosts: ["a"]);
var fired = 0;
pool.Changed += (_, _) => fired++;
await pool.RefreshAsync(TestContext.Current.CancellationToken);
fired.ShouldBeGreaterThan(0);
}
[Fact]
public void Invalid_options_are_rejected_rather_than_misbehaving_quietly()
{
Should.Throw<ArgumentOutOfRangeException>(() => new ProxyOptions { ProbeConcurrency = 0 }.Validated());
Should.Throw<ArgumentOutOfRangeException>(() => new ProxyOptions { LazyProbeAttempts = 0 }.Validated());
Should.Throw<ArgumentOutOfRangeException>(() => new ProxyOptions { ProbeTimeout = TimeSpan.Zero }.Validated());
Should.Throw<ArgumentOutOfRangeException>(() =>
new ProxyOptions
{
BaseQuarantine = TimeSpan.FromHours(2),
MaxQuarantine = TimeSpan.FromMinutes(1),
}.Validated()
);
}
}
@@ -0,0 +1,142 @@
using AvParser.Core.Proxies;
using AvParser.Core.Proxies.Selection;
namespace AvParser.Core.Tests.Proxies;
public class ProxySelectionTests
{
private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Sticky_keeps_returning_the_same_proxy()
{
var strategy = new StickyProxySelection();
ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b")];
var first = strategy.Pick(candidates);
strategy.Pick(candidates).ShouldBeSameAs(first);
strategy.Pick(candidates).ShouldBeSameAs(first);
}
[Fact]
public void Sticky_moves_on_after_a_failure()
{
var strategy = new StickyProxySelection();
ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b")];
var first = strategy.Pick(candidates)!;
strategy.Report(first, success: false);
var remaining = candidates.Where(entry => !ReferenceEquals(entry, first)).ToArray();
strategy.Pick(remaining).ShouldNotBeSameAs(first);
}
[Fact]
public void Sticky_lets_go_when_its_pick_leaves_the_candidate_set()
{
var strategy = new StickyProxySelection();
var a = ProxyFactory.Entry("a");
var b = ProxyFactory.Entry("b");
strategy.Pick([a, b]).ShouldBeSameAs(a);
// A refresh or a quarantine can drop the current pick under us.
strategy.Pick([b]).ShouldBeSameAs(b);
}
[Fact]
public void Sticky_survives_a_success_report()
{
var strategy = new StickyProxySelection();
ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b")];
var first = strategy.Pick(candidates)!;
strategy.Report(first, success: true);
strategy.Pick(candidates).ShouldBeSameAs(first);
}
[Fact]
public void RoundRobin_cycles_through_every_candidate()
{
var strategy = new RoundRobinProxySelection();
ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b"), ProxyFactory.Entry("c")];
var picked = Enumerable.Range(0, 6).Select(_ => strategy.Pick(candidates)!.Endpoint.Host).ToArray();
picked.ShouldBe(["a", "b", "c", "a", "b", "c"]);
}
[Fact]
public void RoundRobin_handles_a_shrinking_candidate_list()
{
var strategy = new RoundRobinProxySelection();
ProxyEntry[] three = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b"), ProxyFactory.Entry("c")];
strategy.Pick(three);
strategy.Pick(three);
strategy.Pick(three);
// The cursor is now past the end of the smaller list; this must not throw.
Should.NotThrow(() => strategy.Pick([three[0]]));
}
[Fact]
public void Every_strategy_returns_null_for_an_empty_pool()
{
foreach (var rotation in Enum.GetValues<ProxyRotation>())
{
ProxySelectionStrategyFactory.Create(rotation, new Random(1)).Pick([]).ShouldBeNull();
}
}
[Fact]
public void Weighted_selection_favours_proxies_that_have_worked()
{
var good = ProxyFactory.Entry("good", score: 5);
var bad = ProxyFactory.Entry("bad", score: 5);
for (var i = 0; i < 20; i++)
{
good.RecordSuccess(Now);
bad.RecordFailure(Now, TimeSpan.Zero, TimeSpan.Zero, failuresBeforeQuarantine: int.MaxValue);
}
var strategy = new WeightedRandomProxySelection(new Random(20260813));
var picks = Enumerable.Range(0, 400).Count(_ => strategy.Pick([good, bad])!.Endpoint.Host == "good");
// Not asserting an exact split — this is a random draw. The point is the bias exists and
// is decisive, not that it hits a particular number.
picks.ShouldBeGreaterThan(340);
}
[Fact]
public void Weight_never_drops_to_zero_so_a_bad_proxy_can_recover()
{
var hopeless = ProxyFactory.Entry("hopeless");
for (var i = 0; i < 50; i++)
{
hopeless.RecordFailure(Now, TimeSpan.Zero, TimeSpan.Zero, failuresBeforeQuarantine: int.MaxValue);
}
WeightedRandomProxySelection.WeightOf(hopeless).ShouldBeGreaterThan(0d);
}
[Fact]
public void An_unproven_proxy_starts_at_even_odds()
{
// Before any evidence, success rate is 0.5 rather than 0 — otherwise a freshly loaded
// pool would have every weight pinned to the floor and selection would be arbitrary.
ProxyFactory.Entry("fresh").SuccessRate.ShouldBe(0.5d);
}
[Fact]
public void The_factory_builds_what_it_was_asked_for()
{
foreach (var rotation in Enum.GetValues<ProxyRotation>())
{
ProxySelectionStrategyFactory.Create(rotation).Kind.ShouldBe(rotation);
}
}
}
@@ -0,0 +1,93 @@
using AvParser.Core.Proxies;
namespace AvParser.Core.Tests.Proxies;
/// <summary>A clock the tests move by hand, so quarantine expiry is deterministic.</summary>
internal sealed class FakeTimeProvider(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public FakeTimeProvider()
: this(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)) { }
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now += delta;
}
/// <summary>A source that returns whatever the test handed it.</summary>
internal sealed class FakeProxySource(ProxySourceKind kind = ProxySourceKind.Feed, params ProxyEndpoint[] endpoints)
: IProxySource
{
public string Id { get; init; } = "fake";
public string DisplayName => "Fake source";
public ProxySourceKind Kind { get; } = kind;
public List<ProxyEndpoint> Endpoints { get; } = [.. endpoints];
public int GetCallCount { get; private set; }
public Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default)
{
GetCallCount++;
return Task.FromResult<IReadOnlyList<ProxyEndpoint>>(Endpoints.ToArray());
}
}
/// <summary>A probe whose verdict the test decides, per address.</summary>
internal sealed class FakeProxyProbe : IProxyProbe
{
private readonly Dictionary<string, bool> _verdicts = new(StringComparer.Ordinal);
/// <summary>Verdict for addresses with no explicit entry.</summary>
public bool DefaultAlive { get; set; }
/// <summary>How many probes were requested.</summary>
public int ProbeCount { get; private set; }
/// <summary>Addresses probed, in order.</summary>
public List<string> Probed { get; } = [];
public FakeProxyProbe Set(ProxyEndpoint endpoint, bool alive)
{
_verdicts[endpoint.Key] = alive;
return this;
}
public Task<ProxyProbeResult> ProbeAsync(
ProxyEndpoint endpoint,
ProxyOptions options,
CancellationToken cancellationToken = default
)
{
ProbeCount++;
Probed.Add(endpoint.Key);
var alive = _verdicts.TryGetValue(endpoint.Key, out var verdict) ? verdict : DefaultAlive;
return Task.FromResult(
alive ? ProxyProbeResult.Success(TimeSpan.FromMilliseconds(42)) : ProxyProbeResult.Failure("dead")
);
}
}
/// <summary>Shorthand builders for the proxy tests.</summary>
internal static class ProxyFactory
{
public static ProxyEndpoint Endpoint(
string host,
int port = 8080,
ProxyProtocol protocol = ProxyProtocol.Http,
int score = 0
) => new(protocol, host, port) { Score = score };
public static ProxyEntry Entry(
string host,
int port = 8080,
ProxyProtocol protocol = ProxyProtocol.Http,
int score = 0,
ProxySourceKind source = ProxySourceKind.Feed
) => new(Endpoint(host, port, protocol, score), source);
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>AvParser.Infrastructure.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AvParser.Core\AvParser.Core.csproj" />
<ProjectReference Include="..\..\src\AvParser.Infrastructure\AvParser.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
@@ -0,0 +1,141 @@
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging.Abstractions;
namespace AvParser.Infrastructure.Tests;
public sealed class CustomProxySourceTests : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(),
"AvParserTests",
Guid.NewGuid().ToString("N")
);
private CustomProxySource Create() => new(new AppPaths(_directory), NullLogger<CustomProxySource>.Instance);
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
[Fact]
public async Task An_absent_file_reads_as_an_empty_list()
{
using var source = Create();
(await source.GetProxiesAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public async Task Added_proxies_survive_a_reload()
{
using (var source = Create())
{
ProxyEndpoint.TryParse("socks5://1.2.3.4:1080", out var endpoint).ShouldBeTrue();
(await source.AddAsync([endpoint!], TestContext.Current.CancellationToken)).ShouldBe(1);
}
// A brand-new instance reads from disk rather than from the in-memory cache.
using var reopened = Create();
var proxies = await reopened.GetProxiesAsync(TestContext.Current.CancellationToken);
proxies.ShouldHaveSingleItem().Port.ShouldBe(1080);
}
[Fact]
public async Task Adding_the_same_address_twice_is_a_no_op()
{
using var source = Create();
ProxyEndpoint.TryParse("1.2.3.4:8080", out var endpoint).ShouldBeTrue();
(await source.AddAsync([endpoint!], TestContext.Current.CancellationToken)).ShouldBe(1);
(await source.AddAsync([endpoint!], TestContext.Current.CancellationToken)).ShouldBe(0);
(await source.GetProxiesAsync(TestContext.Current.CancellationToken)).Count.ShouldBe(1);
}
[Fact]
public async Task Credentials_are_persisted()
{
using (var source = Create())
{
ProxyEndpoint.TryParse("http://alice:s3cret@1.2.3.4:8080", out var endpoint).ShouldBeTrue();
await source.AddAsync([endpoint!], TestContext.Current.CancellationToken);
}
using var reopened = Create();
var proxy = (await reopened.GetProxiesAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
proxy.Username.ShouldBe("alice");
proxy.Password.ShouldBe("s3cret");
}
[Fact]
public async Task Removing_reports_whether_the_address_was_there()
{
using var source = Create();
ProxyEndpoint.TryParse("1.2.3.4:8080", out var endpoint).ShouldBeTrue();
await source.AddAsync([endpoint!], TestContext.Current.CancellationToken);
(await source.RemoveAsync(endpoint!, TestContext.Current.CancellationToken)).ShouldBeTrue();
(await source.RemoveAsync(endpoint!, TestContext.Current.CancellationToken)).ShouldBeFalse();
(await source.GetProxiesAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public async Task Clearing_empties_the_list()
{
using var source = Create();
ProxyEndpoint.TryParse("1.2.3.4:8080", out var a).ShouldBeTrue();
ProxyEndpoint.TryParse("5.6.7.8:8080", out var b).ShouldBeTrue();
await source.AddAsync([a!, b!], TestContext.Current.CancellationToken);
await source.ClearAsync(TestContext.Current.CancellationToken);
(await source.GetProxiesAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public void Pasted_lists_are_split_on_anything_reasonable()
{
var (parsed, rejected) = CustomProxySource.ParseList(
"socks5://1.2.3.4:1080\n2.3.4.5:8080, 3.4.5.6:3128;4.5.6.7:80"
);
parsed.Count.ShouldBe(4);
rejected.ShouldBeEmpty();
}
[Fact]
public void Comments_and_blank_lines_are_ignored()
{
var (parsed, rejected) = CustomProxySource.ParseList("# my proxies\n\n1.2.3.4:8080\n");
parsed.ShouldHaveSingleItem();
rejected.ShouldBeEmpty();
}
[Fact]
public void Bad_lines_are_reported_rather_than_dropped()
{
// Silently accepting 2 of 3 is impossible to act on when the paste is hundreds long.
var (parsed, rejected) = CustomProxySource.ParseList("1.2.3.4:8080\nnot-a-proxy\n5.6.7.8:1080");
parsed.Count.ShouldBe(2);
rejected.ShouldHaveSingleItem().ShouldBe("not-a-proxy");
}
[Fact]
public void An_empty_paste_yields_nothing()
{
var (parsed, rejected) = CustomProxySource.ParseList(" ");
parsed.ShouldBeEmpty();
rejected.ShouldBeEmpty();
}
}
@@ -0,0 +1,115 @@
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
namespace AvParser.Infrastructure.Tests;
public class ProxiflyFeedTests
{
/// <summary>Captured verbatim from the live feed, so the schema is pinned by a real payload.</summary>
private const string Sample = """
[
{
"proxy": "socks5://208.102.51.6:58208",
"protocol": "socks5",
"ip": "208.102.51.6",
"port": 58208,
"https": false,
"anonymity": "transparent",
"score": 1,
"geolocation": { "country": "US", "city": "Unknown" }
},
{
"proxy": "http://45.61.98.1:3128",
"protocol": "http",
"ip": "45.61.98.1",
"port": 3128,
"https": true,
"anonymity": "elite",
"score": 4,
"geolocation": { "country": "DE", "city": "Berlin" }
}
]
""";
[Fact]
public void Parses_the_published_schema()
{
var endpoints = ProxiflyProxySource.ParseFeed(Sample);
endpoints.Count.ShouldBe(2);
var socks = endpoints[0];
socks.Protocol.ShouldBe(ProxyProtocol.Socks5);
socks.Host.ShouldBe("208.102.51.6");
socks.Port.ShouldBe(58208);
socks.Country.ShouldBe("US");
socks.Anonymity.ShouldBe(ProxyAnonymity.Transparent);
socks.Score.ShouldBe(1);
}
[Fact]
public void Keeps_a_real_city_and_drops_the_Unknown_placeholder()
{
var endpoints = ProxiflyProxySource.ParseFeed(Sample);
// The feed writes the literal string "Unknown" rather than omitting the field; showing
// that in the UI would be worse than showing nothing.
endpoints[0].City.ShouldBeNull();
endpoints[1].City.ShouldBe("Berlin");
}
[Fact]
public void An_empty_feed_is_not_an_error() => ProxiflyProxySource.ParseFeed("[]").ShouldBeEmpty();
[Fact]
public void Falls_back_to_the_proxy_field_when_the_parts_are_missing()
{
var endpoint = ProxiflyProxySource.ToEndpoint(
new ProxiflyRecord { Proxy = "socks4://9.9.9.9:1080", Protocol = "socks4" }
);
endpoint.ShouldNotBeNull();
endpoint.Protocol.ShouldBe(ProxyProtocol.Socks4);
endpoint.Port.ShouldBe(1080);
}
[Fact]
public void Skips_a_record_it_cannot_make_sense_of() =>
ProxiflyProxySource
.ToEndpoint(
new ProxiflyRecord
{
Protocol = "gopher",
Ip = "1.2.3.4",
Port = 80,
}
)
.ShouldBeNull();
[Fact]
public void Skips_a_record_with_an_impossible_port() =>
ProxiflyProxySource
.ToEndpoint(
new ProxiflyRecord
{
Protocol = "http",
Ip = "1.2.3.4",
Port = 0,
}
)
.ShouldBeNull();
[Fact]
public void A_malformed_row_does_not_take_the_whole_feed_down()
{
const string mixed = """
[
{ "protocol": "http", "ip": "1.2.3.4", "port": 8080 },
{ "protocol": "nonsense", "ip": "5.6.7.8", "port": 9 },
{ "protocol": "socks5", "ip": "9.9.9.9", "port": 1080 }
]
""";
ProxiflyProxySource.ParseFeed(mixed).Count.ShouldBe(2);
}
}
@@ -0,0 +1,82 @@
using System.Net;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
namespace AvParser.Infrastructure.Tests;
public class ProxyHandlerFactoryTests
{
[Theory]
[InlineData(ProxyProtocol.Http, "http://1.2.3.4:8080/")]
[InlineData(ProxyProtocol.Https, "http://1.2.3.4:8080/")]
[InlineData(ProxyProtocol.Socks4, "socks4://1.2.3.4:8080/")]
[InlineData(ProxyProtocol.Socks5, "socks5://1.2.3.4:8080/")]
public void Maps_the_protocol_onto_a_scheme_dotnet_understands(ProxyProtocol protocol, string expected)
{
var proxy = ProxyHandlerFactory.CreateWebProxy(new ProxyEndpoint(protocol, "1.2.3.4", 8080));
proxy.Address!.ToString().ShouldBe(expected);
}
[Fact]
public void Attaches_credentials_when_present()
{
var endpoint = new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080)
{
Username = "alice",
Password = "s3cret",
};
var credentials = ProxyHandlerFactory.CreateWebProxy(endpoint).Credentials.ShouldBeOfType<NetworkCredential>();
credentials.UserName.ShouldBe("alice");
credentials.Password.ShouldBe("s3cret");
}
[Fact]
public void Leaves_credentials_alone_when_there_are_none() =>
ProxyHandlerFactory
.CreateWebProxy(new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080))
.Credentials.ShouldBeNull();
[Fact]
public void A_null_endpoint_produces_a_direct_handler()
{
using var handler = ProxyHandlerFactory.CreateHandler(null, TimeSpan.FromSeconds(5));
handler.UseProxy.ShouldBeFalse();
handler.Proxy.ShouldBeNull();
}
[Fact]
public void An_endpoint_produces_a_proxied_handler()
{
using var handler = ProxyHandlerFactory.CreateHandler(
new ProxyEndpoint(ProxyProtocol.Socks5, "1.2.3.4", 1080),
TimeSpan.FromSeconds(5)
);
handler.UseProxy.ShouldBeTrue();
handler.Proxy.ShouldNotBeNull();
handler.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void The_client_factory_honours_the_requested_timeout()
{
var factory = new ProxiedHttpClientFactory(new ProxyPool([], new NeverProbe(), new ProxyOptions()));
using var client = factory.Create(null, TimeSpan.FromSeconds(3));
client.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
}
private sealed class NeverProbe : IProxyProbe
{
public Task<ProxyProbeResult> ProbeAsync(
ProxyEndpoint endpoint,
ProxyOptions options,
CancellationToken cancellationToken = default
) => Task.FromResult(ProxyProbeResult.Failure("not used"));
}
}
+41
View File
@@ -1,3 +1,4 @@
using AvParser.Core.Proxies;
using AvParser.Core.Settings; using AvParser.Core.Settings;
using AvParser.UI.Services; using AvParser.UI.Services;
using AvParser.UI.ViewModels; using AvParser.UI.ViewModels;
@@ -39,3 +40,43 @@ internal sealed class FakeThemeService(AppTheme initial = AppTheme.System) : ITh
public void Dispose() => _current.Dispose(); public void Dispose() => _current.Dispose();
} }
/// <summary>An editable proxy list held in memory.</summary>
internal sealed class FakeMutableProxySource : IMutableProxySource
{
public string Id => "fake-custom";
public string DisplayName => "Fake custom list";
public ProxySourceKind Kind => ProxySourceKind.Custom;
public List<ProxyEndpoint> Endpoints { get; } = [];
public Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<ProxyEndpoint>>(Endpoints.ToArray());
public Task<int> AddAsync(IEnumerable<ProxyEndpoint> endpoints, CancellationToken cancellationToken = default)
{
Endpoints.AddRange(endpoints);
return Task.FromResult(Endpoints.Count);
}
public Task<bool> RemoveAsync(ProxyEndpoint endpoint, CancellationToken cancellationToken = default) =>
Task.FromResult(Endpoints.RemoveAll(e => e.Key == endpoint.Key) > 0);
public Task ClearAsync(CancellationToken cancellationToken = default)
{
Endpoints.Clear();
return Task.CompletedTask;
}
}
/// <summary>A probe that says everything is dead. The view never calls it in these tests.</summary>
internal sealed class FakeProxyProbe : IProxyProbe
{
public Task<ProxyProbeResult> ProbeAsync(
ProxyEndpoint endpoint,
ProxyOptions options,
CancellationToken cancellationToken = default
) => Task.FromResult(ProxyProbeResult.Failure("not probed in tests"));
}
@@ -0,0 +1,81 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Proxies;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using Microsoft.Extensions.Logging.Abstractions;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.HeadlessTests;
public class ProxiesViewTests
{
private static (ProxiesView View, ProxiesViewModel ViewModel, Window Window) ShowPage(
params ProxyEndpoint[] endpoints
)
{
var custom = new FakeMutableProxySource();
custom.Endpoints.AddRange(endpoints);
var pool = new ProxyPool([custom], new FakeProxyProbe(), new ProxyOptions());
var viewModel = new ProxiesViewModel(
pool,
custom,
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
var view = new ProxiesView { DataContext = viewModel };
var window = new Window
{
Width = 1400,
Height = 800,
Content = view,
};
window.Show();
Dispatcher.UIThread.RunJobs();
return (view, viewModel, window);
}
[AvaloniaFact]
public void The_page_renders_with_an_empty_pool()
{
var (view, _, _) = ShowPage();
view.GetVisualDescendants().OfType<ListBox>().ShouldNotBeEmpty();
}
[AvaloniaFact]
public async Task Refreshing_puts_rows_on_screen()
{
var (view, viewModel, _) = ShowPage(
new ProxyEndpoint(ProxyProtocol.Socks5, "10.0.0.1", 1080),
new ProxyEndpoint(ProxyProtocol.Http, "10.0.0.2", 8080)
);
await viewModel.RefreshCommand.Execute().ToTask(TestContext.Current.CancellationToken);
Dispatcher.UIThread.RunJobs();
var list = view.GetVisualDescendants().OfType<ListBox>().First();
list.ItemCount.ShouldBe(2);
viewModel.TotalCount.ShouldBe(2);
}
[AvaloniaFact]
public async Task Rows_carry_the_address_and_the_source()
{
var (_, viewModel, _) = ShowPage(new ProxyEndpoint(ProxyProtocol.Socks5, "10.0.0.1", 1080));
await viewModel.RefreshCommand.Execute().ToTask(TestContext.Current.CancellationToken);
Dispatcher.UIThread.RunJobs();
var row = viewModel.Proxies.ShouldHaveSingleItem();
row.Address.ShouldBe("socks5://10.0.0.1:1080");
row.Source.ShouldBe("custom");
row.HealthText.ShouldBe("unchecked");
}
}
@@ -70,6 +70,8 @@ public class ViewLocatorTests
public string SettingsFile => Path.Combine(DataDirectory, "settings.json"); public string SettingsFile => Path.Combine(DataDirectory, "settings.json");
public string CustomProxiesFile => Path.Combine(DataDirectory, "proxies.custom.json");
public string LogDirectory => Path.Combine(DataDirectory, "logs"); public string LogDirectory => Path.Combine(DataDirectory, "logs");
} }
} }
@@ -0,0 +1,84 @@
using AvParser.Core.Proxies;
namespace AvParser.UI.Tests.Fakes;
/// <summary>A feed whose contents the test supplies.</summary>
internal sealed class FakeProxySource(IEnumerable<ProxyEndpoint>? endpoints = null) : IProxySource
{
public string Id => "fake-feed";
public string DisplayName => "Fake feed";
public ProxySourceKind Kind => ProxySourceKind.Feed;
public List<ProxyEndpoint> Endpoints { get; } = [.. endpoints ?? []];
public Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<ProxyEndpoint>>(Endpoints.ToArray());
}
/// <summary>An in-memory stand-in for the user's editable list.</summary>
internal sealed class FakeMutableProxySource : IMutableProxySource
{
public string Id => "fake-custom";
public string DisplayName => "Fake custom list";
public ProxySourceKind Kind => ProxySourceKind.Custom;
public List<ProxyEndpoint> Endpoints { get; } = [];
public Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<ProxyEndpoint>>(Endpoints.ToArray());
public Task<int> AddAsync(IEnumerable<ProxyEndpoint> endpoints, CancellationToken cancellationToken = default)
{
var added = 0;
foreach (var endpoint in endpoints)
{
if (Endpoints.Any(existing => string.Equals(existing.Key, endpoint.Key, StringComparison.Ordinal)))
{
continue;
}
Endpoints.Add(endpoint);
added++;
}
return Task.FromResult(added);
}
public Task<bool> RemoveAsync(ProxyEndpoint endpoint, CancellationToken cancellationToken = default) =>
Task.FromResult(
Endpoints.RemoveAll(existing => string.Equals(existing.Key, endpoint.Key, StringComparison.Ordinal)) > 0
);
public Task ClearAsync(CancellationToken cancellationToken = default)
{
Endpoints.Clear();
return Task.CompletedTask;
}
}
/// <summary>A probe whose verdict the test decides.</summary>
internal sealed class FakeProxyProbe : IProxyProbe
{
private readonly Dictionary<string, bool> _verdicts = new(StringComparer.Ordinal);
public FakeProxyProbe Set(ProxyEndpoint endpoint, bool alive)
{
_verdicts[endpoint.Key] = alive;
return this;
}
public Task<ProxyProbeResult> ProbeAsync(
ProxyEndpoint endpoint,
ProxyOptions options,
CancellationToken cancellationToken = default
) =>
Task.FromResult(
_verdicts.TryGetValue(endpoint.Key, out var alive) && alive
? ProxyProbeResult.Success(TimeSpan.FromMilliseconds(20))
: ProxyProbeResult.Failure("dead")
);
}
+14 -1
View File
@@ -29,6 +29,18 @@ public class ParseViewModelTests
private static Task RunAsync(ParseViewModel page) => page.ParseCommand.Execute().ToTask(); private static Task RunAsync(ParseViewModel page) => page.ParseCommand.Execute().ToTask();
/// <summary>
/// Waits until a command's gate opens.
/// </summary>
/// <remarks>
/// <c>Execute()</c> completing and <c>IsExecuting</c> going false are not the same instant:
/// the latter is published on the output scheduler. Commands gated on another command's
/// IsExecuting therefore need the gate observed, not assumed — asserting straight after the
/// await failed intermittently under load.
/// </remarks>
private static Task WhenExecutable<TParam, TResult>(ReactiveUI.ReactiveCommand<TParam, TResult> command) =>
command.CanExecute.Where(static can => can).Take(1).ToTask(TestContext.Current.CancellationToken);
[Fact] [Fact]
public void Restores_the_last_used_parser() public void Restores_the_last_used_parser()
{ {
@@ -144,7 +156,8 @@ public class ParseViewModelTests
page.InputText = "id,name\n1,Ada"; page.InputText = "id,name\n1,Ada";
await RunAsync(page); await RunAsync(page);
page.ClearCommand.Execute().Subscribe(_ => { }); await WhenExecutable(page.ClearCommand);
await page.ClearCommand.Execute().ToTask(TestContext.Current.CancellationToken);
page.InputText.ShouldBeEmpty(); page.InputText.ShouldBeEmpty();
page.Records.ShouldBeEmpty(); page.Records.ShouldBeEmpty();
@@ -0,0 +1,199 @@
using AvParser.Core.Proxies;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.Tests;
public class ProxiesViewModelTests
{
private static (ProxiesViewModel Page, ProxyPool Pool, FakeMutableProxySource Custom) Build(params string[] hosts)
{
var custom = new FakeMutableProxySource();
var feed = new FakeProxySource(hosts.Select(host => Endpoint(host)));
var pool = new ProxyPool(
[feed, custom],
new FakeProxyProbe(),
new ProxyOptions(),
timeProvider: null,
random: new Random(1)
);
var page = new ProxiesViewModel(
pool,
custom,
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
return (page, pool, custom);
}
private static ProxyEndpoint Endpoint(string host, ProxyProtocol protocol = ProxyProtocol.Http) =>
new(protocol, host, 8080);
/// <summary>Runs a command to completion under the ambient test cancellation token.</summary>
private static Task Run<TResult>(ReactiveUI.ReactiveCommand<RxVoid, TResult> command) =>
command.Execute().ToTask(TestContext.Current.CancellationToken);
[Fact]
public void Starts_empty()
{
var (page, _, _) = Build();
page.Proxies.ShouldBeEmpty();
page.TotalCount.ShouldBe(0);
}
[Fact]
public async Task Refreshing_fills_the_list()
{
var (page, _, _) = Build("a", "b");
await Run(page.RefreshCommand);
page.Proxies.Count.ShouldBe(2);
page.TotalCount.ShouldBe(2);
page.StatusMessage.ShouldNotBeNull();
}
[Fact]
public async Task The_search_box_filters_by_address()
{
var (page, _, _) = Build("10.0.0.1", "10.0.0.2");
await Run(page.RefreshCommand);
page.SearchText = "10.0.0.2";
await Task.Delay(250, TestContext.Current.CancellationToken);
page.Proxies.ShouldHaveSingleItem().Address.ShouldContain("10.0.0.2");
}
[Fact]
public async Task The_protocol_filter_narrows_the_list()
{
var custom = new FakeMutableProxySource();
var feed = new FakeProxySource([Endpoint("http-one"), Endpoint("socks-one", ProxyProtocol.Socks5)]);
var pool = new ProxyPool([feed, custom], new FakeProxyProbe(), new ProxyOptions());
var page = new ProxiesViewModel(
pool,
custom,
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
await Run(page.RefreshCommand);
page.ProtocolFilter = ProxyProtocolFilter.Socks5;
await Task.Delay(250, TestContext.Current.CancellationToken);
page.Proxies.ShouldHaveSingleItem().Protocol.ShouldBe("SOCKS5");
}
[Fact]
public async Task Adding_custom_proxies_reports_what_it_could_not_parse()
{
var (page, _, custom) = Build();
page.NewProxies = "1.2.3.4:8080\nnot-a-proxy";
await Run(page.AddCustomCommand);
custom.Endpoints.Count.ShouldBe(1);
page.StatusMessage!.ShouldContain("not-a-proxy");
}
[Fact]
public async Task The_input_box_is_cleared_only_when_something_was_added()
{
var (page, _, _) = Build();
page.NewProxies = "not-a-proxy";
await Run(page.AddCustomCommand);
page.NewProxies.ShouldBe("not-a-proxy");
page.NewProxies = "1.2.3.4:8080";
await Run(page.AddCustomCommand);
page.NewProxies.ShouldBeEmpty();
}
[Fact]
public async Task Removing_is_offered_only_for_custom_entries()
{
var (page, _, _) = Build("feed-one");
await Run(page.RefreshCommand);
var canRemove = true;
using var subscription = page.RemoveSelectedCommand.CanExecute.Subscribe(value => canRemove = value);
page.SelectedProxy = page.Proxies.Single();
// Feed entries are republished upstream; removing one locally would be undone on the
// next refresh, so the command stays disabled.
canRemove.ShouldBeFalse();
}
[Fact]
public async Task A_custom_entry_can_be_removed()
{
var (page, _, custom) = Build();
page.NewProxies = "1.2.3.4:8080";
await Run(page.AddCustomCommand);
page.SelectedProxy = page.Proxies.Single();
page.SelectedProxy.IsCustom.ShouldBeTrue();
await Run(page.RemoveSelectedCommand);
custom.Endpoints.ShouldBeEmpty();
page.Proxies.ShouldBeEmpty();
}
[Fact]
public async Task Sweeping_updates_the_alive_count()
{
var custom = new FakeMutableProxySource();
var feed = new FakeProxySource([Endpoint("good"), Endpoint("bad")]);
var probe = new FakeProxyProbe();
probe.Set(Endpoint("good"), alive: true);
var pool = new ProxyPool([feed, custom], probe, new ProxyOptions());
var page = new ProxiesViewModel(
pool,
custom,
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
await Run(page.RefreshCommand);
await Run(page.SweepCommand);
page.AliveCount.ShouldBe(1);
page.IsSweeping.ShouldBeFalse();
page.StatusMessage!.ShouldContain("1 of 2");
}
[Fact]
public async Task Clearing_empties_the_custom_list()
{
var (page, _, custom) = Build();
page.NewProxies = "1.2.3.4:8080\n5.6.7.8:1080";
await Run(page.AddCustomCommand);
await Run(page.ClearCustomCommand);
custom.Endpoints.ShouldBeEmpty();
page.Proxies.ShouldBeEmpty();
}
[Fact]
public void Disposing_detaches_from_the_pool()
{
var (page, pool, _) = Build("a");
page.Dispose();
// The pool is a singleton; a page that stayed subscribed would be kept alive forever
// and would keep rebuilding its rows in the background.
Should.NotThrow(() => pool.Configure(new ProxyOptions()));
}
}
@@ -2,6 +2,11 @@ using System.Runtime.CompilerServices;
using ReactiveUI.Builder; using ReactiveUI.Builder;
using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Concurrency;
// The bootstrap below installs process-global ReactiveUI schedulers, so these tests share mutable
// state whether they like it or not. Running them in parallel made assertions that depend on a
// command's IsExecuting having settled fail intermittently under load.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace AvParser.UI.Tests; namespace AvParser.UI.Tests;
/// <summary>Initialises ReactiveUI once for the whole test assembly.</summary> /// <summary>Initialises ReactiveUI once for the whole test assembly.</summary>