Gate network parsers on a working proxy and remember what worked
The pool now warms up from what the previous run learned instead of starting cold every launch. Startup probes the remembered proxies first, stops as soon as ProxyMinimumLive of them answer, and writes the survivors to proxies.state.json after the warm-up and again on shutdown. Only proxies that ever answered are stored: the feed republishes a few thousand dead addresses every five minutes, and "was dead an hour ago" says almost nothing. Remembered state is a hint, not a verdict. A restored proxy sorts first in the warm-up queue but is not counted live until it answers in this session - otherwise a launch a week later would report live proxies it had never spoken to, the warm-up would skip the very entries it exists to re-check, and the parser gate would open on week-old evidence. That gate is the other half: a parser declaring RequiresNetwork will not run while the pool has nothing live. The Parse page disables the run button and shows a banner that leads to the Proxies page. Parsers that work on pasted text are never gated - they have nothing to route, and blocking them would make the app useless whenever the public lists are down. Two new settings cover the escape hatch and the target: "allow network parsers without a proxy" and how many live proxies to find at startup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
85656e70b0
commit
44fb0d3a5f
@@ -3,12 +3,16 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Proxies;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Localization;
|
||||
using AvParser.UI.Navigation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Primitives;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
using ReactiveUI.Primitives.Signals;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
@@ -18,7 +22,7 @@ namespace AvParser.UI.ViewModels;
|
||||
/// This page exists to exercise the whole <see cref="IParser{TInput,TOutput}"/> contract —
|
||||
/// streaming, progress and cancellation — rather than to be a finished feature.
|
||||
/// </remarks>
|
||||
public partial class ParseViewModel : PageViewModel
|
||||
public partial class ParseViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
/// <summary>Records buffered before being pushed to the UI collection in one go.</summary>
|
||||
private const int BatchSize = 512;
|
||||
@@ -31,10 +35,14 @@ public partial class ParseViewModel : PageViewModel
|
||||
|
||||
private readonly IParserCatalog _catalog;
|
||||
private readonly ISettingsService _settings;
|
||||
private readonly IProxyPool _proxyPool;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<ParseViewModel> _logger;
|
||||
private readonly ISequencer _mainThread;
|
||||
private readonly ObservableAsPropertyHelper<bool> _isBusy;
|
||||
|
||||
private readonly Signal<RxVoid> _proxyChanged = new();
|
||||
|
||||
private CancellationTokenSource? _cancellation;
|
||||
|
||||
/// <summary>Text to parse.</summary>
|
||||
@@ -53,9 +61,21 @@ public partial class ParseViewModel : PageViewModel
|
||||
[Reactive]
|
||||
public partial string? StatusMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected parser needs the network but has no working proxy to use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only network parsers are gated. A parser that works on text the user pasted has nothing to
|
||||
/// route, and blocking it would make the app unusable whenever the public lists are down.
|
||||
/// </remarks>
|
||||
[Reactive]
|
||||
public partial bool IsBlockedWithoutProxy { get; set; }
|
||||
|
||||
/// <summary>Creates the page.</summary>
|
||||
/// <param name="catalog">Available parsers.</param>
|
||||
/// <param name="settings">Used to remember the selected parser.</param>
|
||||
/// <param name="proxyPool">Consulted for the live count that gates network parsers.</param>
|
||||
/// <param name="services">Resolves the navigation service lazily, to keep pages acyclic.</param>
|
||||
/// <param name="logger">Diagnostics.</param>
|
||||
/// <param name="mainThread">
|
||||
/// Scheduler used to marshal collection and progress updates back to the UI thread. Tests
|
||||
@@ -64,12 +84,16 @@ public partial class ParseViewModel : PageViewModel
|
||||
public ParseViewModel(
|
||||
IParserCatalog catalog,
|
||||
ISettingsService settings,
|
||||
IProxyPool proxyPool,
|
||||
IServiceProvider services,
|
||||
ILogger<ParseViewModel> logger,
|
||||
ISequencer? mainThread = null
|
||||
)
|
||||
{
|
||||
_catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
_proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool));
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
|
||||
|
||||
@@ -77,11 +101,30 @@ public partial class ParseViewModel : PageViewModel
|
||||
Parsers = [.. catalog.Parsers.Select(parser => new ParserViewModel(parser))];
|
||||
SelectedParser = Parsers.First(parser => parser.Id == catalog.FindOrDefault(settings.Current.LastParserId).Id);
|
||||
|
||||
var canParse = this.WhenAnyValue(x => x.InputText)
|
||||
.Select(static text => !string.IsNullOrWhiteSpace(text))
|
||||
// The pool changes on every lease outcome and on every probe, so coalesce before
|
||||
// re-evaluating whether the parser is allowed to run.
|
||||
_proxyPool.Changed += OnProxyPoolChanged;
|
||||
_proxyChanged
|
||||
.Throttle(TimeSpan.FromMilliseconds(250), _mainThread)
|
||||
.ObserveOn(_mainThread)
|
||||
.Subscribe(_ => RefreshProxyGate());
|
||||
|
||||
RefreshProxyGate();
|
||||
|
||||
var canParse = this.WhenAnyValue(
|
||||
x => x.InputText,
|
||||
x => x.IsBlockedWithoutProxy,
|
||||
static (text, blocked) => (text, blocked)
|
||||
)
|
||||
.Select(static state => !string.IsNullOrWhiteSpace(state.text) && !state.blocked)
|
||||
.DistinctUntilChanged();
|
||||
|
||||
ParseCommand = ReactiveCommand.CreateFromTask(RunParseAsync, canParse, _mainThread);
|
||||
|
||||
GoToProxiesCommand = ReactiveCommand.Create(
|
||||
() => _services.GetRequiredService<INavigationService>().NavigateTo<ProxiesViewModel>(),
|
||||
outputScheduler: _mainThread
|
||||
);
|
||||
_isBusy = ParseCommand.IsExecuting.ToProperty(this, nameof(IsBusy), false, _mainThread);
|
||||
|
||||
CancelCommand = ReactiveCommand.Create(() => _cancellation?.Cancel(), ParseCommand.IsExecuting, _mainThread);
|
||||
@@ -111,9 +154,17 @@ public partial class ParseViewModel : PageViewModel
|
||||
);
|
||||
|
||||
// Remember the parser choice; the debounced settings service coalesces the writes.
|
||||
// Switching parser can also change whether the gate applies, since only network parsers
|
||||
// are gated.
|
||||
this.WhenAnyValue(x => x.SelectedParser)
|
||||
.Where(static parser => parser is not null)
|
||||
.Subscribe(parser => _settings.Update(current => current with { LastParserId = parser.Id }));
|
||||
.Subscribe(parser =>
|
||||
{
|
||||
_settings.Update(current => current with { LastParserId = parser.Id });
|
||||
RefreshProxyGate();
|
||||
});
|
||||
|
||||
_settings.Changes.Subscribe(_ => RefreshProxyGate());
|
||||
|
||||
// Errors surfacing from any command must not tear the process down.
|
||||
ParseCommand.ThrownExceptions.Subscribe(OnCommandFailed);
|
||||
@@ -152,6 +203,23 @@ public partial class ParseViewModel : PageViewModel
|
||||
/// <summary>Fills the input with 50 000 rows, so progress and cancellation are observable.</summary>
|
||||
public ReactiveCommand<RxVoid, string> GenerateLargeSampleCommand { get; }
|
||||
|
||||
/// <summary>Takes the user to the page where the proxy problem can be fixed.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> GoToProxiesCommand { get; }
|
||||
|
||||
/// <summary>Explains why parsing is blocked.</summary>
|
||||
public string ProxyRequiredMessage => Localizer.Instance["Parse.ProxyRequired"];
|
||||
|
||||
/// <summary>Re-evaluates the proxy gate. Exposed so tests can drive it without waiting.</summary>
|
||||
public void RefreshProxyGate()
|
||||
{
|
||||
var settings = _settings.Current;
|
||||
|
||||
IsBlockedWithoutProxy =
|
||||
SelectedParser.Parser.RequiresNetwork && !settings.AllowDirectConnection && _proxyPool.LiveCount == 0;
|
||||
}
|
||||
|
||||
private void OnProxyPoolChanged(object? sender, EventArgs e) => _proxyChanged.OnNext(RxVoid.Default);
|
||||
|
||||
private async Task RunParseAsync(CancellationToken commandToken)
|
||||
{
|
||||
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(commandToken);
|
||||
@@ -286,11 +354,22 @@ public partial class ParseViewModel : PageViewModel
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The pool is a singleton and would otherwise keep this page alive for the process.</remarks>
|
||||
public void Dispose()
|
||||
{
|
||||
_proxyPool.Changed -= OnProxyPoolChanged;
|
||||
_proxyChanged.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnLanguageChanged()
|
||||
{
|
||||
base.OnLanguageChanged();
|
||||
|
||||
this.RaisePropertyChanged(nameof(ProxyRequiredMessage));
|
||||
|
||||
// The summary and the listed errors were both rendered in the previous language.
|
||||
StatusMessage = null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user