diff --git a/src/AvParser.Core/Settings/AppSettings.cs b/src/AvParser.Core/Settings/AppSettings.cs index 95d66d2..2a4ae7a 100644 --- a/src/AvParser.Core/Settings/AppSettings.cs +++ b/src/AvParser.Core/Settings/AppSettings.cs @@ -19,57 +19,54 @@ public enum AppTheme /// Everything the app remembers between runs. Persisted verbatim as JSON. /// /// -/// A mutable record with defaults on every property: a settings file written by an older -/// version must still deserialise, so no property may be required. +/// +/// Defaults live on the primary constructor parameters, not on property initialisers. +/// That is not a style choice: the source-generated serialiser constructs the object without +/// running property initialisers, so a settings file written by an older build came back with +/// default(T) for every setting added since — which silently switched the proxy feed off +/// and zeroed the probe timeout. Constructor parameter defaults are applied for absent JSON +/// members, so an old file now upgrades cleanly. +/// +/// +/// No property may be required, for the same reason: an old file must still deserialise. +/// /// -public sealed record AppSettings +/// Chosen theme variant. +/// Chosen UI language. +/// Id of the parser selected last time; resolved leniently on load. +/// Last main-window width in device-independent pixels. +/// Last main-window height in device-independent pixels. +/// Whether the main window was maximised on exit. +/// Minimum Serilog level, as a Serilog level name. +/// How the pool picks the next proxy. +/// When proxy liveness is verified. +/// Whether the remote proxy feed is consulted. +/// Protocols accepted when loading proxy sources. +/// URL fetched to decide whether a proxy works. +/// Per-proxy probe timeout, in seconds. +/// How many probes run at once during a pool sweep. +public sealed record AppSettings( + AppTheme Theme = AppTheme.System, + AppLanguage Language = AppLanguage.System, + string? LastParserId = null, + double WindowWidth = 1280, + double WindowHeight = 800, + bool WindowMaximized = false, + string MinimumLogLevel = "Information", + ProxyRotation ProxyRotation = ProxyRotation.Sticky, + ProxyHealthCheck ProxyHealthCheck = ProxyHealthCheck.Pool, + bool ProxyUseFeed = true, + ProxyProtocolFilter ProxyProtocols = ProxyProtocolFilter.All, + string ProxyProbeUrl = "http://www.gstatic.com/generate_204", + int ProxyProbeTimeoutSeconds = 8, + int ProxyProbeConcurrency = 64 +) { - /// Chosen theme variant. - public AppTheme Theme { get; init; } = AppTheme.System; - - /// Chosen UI language. - public AppLanguage Language { get; init; } = AppLanguage.System; - - /// Id of the parser selected last time; resolved leniently on load. - public string? LastParserId { get; init; } - - /// Last main-window width in device-independent pixels. - public double WindowWidth { get; init; } = 1280; - - /// Last main-window height in device-independent pixels. - public double WindowHeight { get; init; } = 800; - - /// Whether the main window was maximised on exit. - public bool WindowMaximized { get; init; } - - /// Minimum Serilog level, as a Serilog level name. - public string MinimumLogLevel { get; init; } = "Information"; - - /// How the pool picks the next proxy. - public ProxyRotation ProxyRotation { get; init; } = ProxyRotation.Sticky; - - /// When proxy liveness is verified. - public ProxyHealthCheck ProxyHealthCheck { get; init; } = ProxyHealthCheck.Pool; - - /// Whether the remote proxy feed is consulted. - public bool ProxyUseFeed { get; init; } = true; - - /// Protocols accepted when loading proxy sources. - public ProxyProtocolFilter ProxyProtocols { get; init; } = ProxyProtocolFilter.All; - - /// URL fetched to decide whether a proxy works. - public string ProxyProbeUrl { get; init; } = "http://www.gstatic.com/generate_204"; - - /// Per-proxy probe timeout, in seconds. - public int ProxyProbeTimeoutSeconds { get; init; } = 8; - - /// How many probes run at once during a pool sweep. - public int ProxyProbeConcurrency { get; init; } = 64; - /// Projects the proxy-related settings onto . /// /// 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. + /// validated options object. This is the single place that bridges the two, and it clamps + /// rather than throws so a hand-edited file cannot stop the app from starting. /// public ProxyOptions ToProxyOptions() { @@ -82,7 +79,7 @@ public sealed record AppSettings Rotation = ProxyRotation, HealthCheck = ProxyHealthCheck, UseFeed = ProxyUseFeed, - Protocols = ProxyProtocols, + Protocols = ProxyProtocols == ProxyProtocolFilter.None ? ProxyProtocolFilter.All : ProxyProtocols, ProbeUrl = probeUrl, ProbeTimeout = TimeSpan.FromSeconds(Math.Clamp(ProxyProbeTimeoutSeconds, 1, 120)), ProbeConcurrency = Math.Clamp(ProxyProbeConcurrency, 1, 512), diff --git a/src/AvParser.Desktop/App.axaml.cs b/src/AvParser.Desktop/App.axaml.cs index ed2c1ac..08adb36 100644 --- a/src/AvParser.Desktop/App.axaml.cs +++ b/src/AvParser.Desktop/App.axaml.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using AvParser.Core.Settings; +using AvParser.Infrastructure.Proxies; using AvParser.UI; using AvParser.UI.Services; using AvParser.UI.ViewModels; @@ -57,6 +58,11 @@ public partial class App : Application desktop.ShutdownRequested += (_, _) => settings.FlushAsync().GetAwaiter().GetResult(); } + // Start filling the proxy pool as soon as the window is up. Not awaited on purpose: the + // load is a network round trip, and blocking startup on a public list being reachable + // would be the wrong trade. It never throws, so there is nothing to observe. + _ = _services.GetRequiredService().EnsureLoadedAsync(); + base.OnFrameworkInitializationCompleted(); } diff --git a/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 926851d..b0dee02 100644 --- a/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -75,6 +75,7 @@ public static class InfrastructureServiceCollectionExtensions )); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/AvParser.Infrastructure/Logging/AppLogging.cs b/src/AvParser.Infrastructure/Logging/AppLogging.cs index 60a41d8..79fb6cb 100644 --- a/src/AvParser.Infrastructure/Logging/AppLogging.cs +++ b/src/AvParser.Infrastructure/Logging/AppLogging.cs @@ -30,6 +30,9 @@ public static class AppLogging var logger = new LoggerConfiguration() .MinimumLevel.ControlledBy(levelSwitch) + // IHttpClientFactory logs four Information lines per request. At Information that + // buries everything the app itself says, and the proxy sweep makes thousands of them. + .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning) .Enrich.FromLogContext() .WriteTo.Console(outputTemplate: OutputTemplate) .WriteTo.File( diff --git a/src/AvParser.Infrastructure/Proxies/ProxyPoolLoader.cs b/src/AvParser.Infrastructure/Proxies/ProxyPoolLoader.cs new file mode 100644 index 0000000..e838361 --- /dev/null +++ b/src/AvParser.Infrastructure/Proxies/ProxyPoolLoader.cs @@ -0,0 +1,75 @@ +using AvParser.Core.Proxies; +using Microsoft.Extensions.Logging; + +namespace AvParser.Infrastructure.Proxies; + +/// Fills the pool once at startup, so nothing has to be loaded by hand. +public interface IProxyPoolLoader +{ + /// + /// Loads every source, once per process. Later callers get the same operation rather than a + /// second download. + /// + /// How many proxies the pool holds afterwards. + /// Never throws: a source that is down leaves the pool as it was. + Task EnsureLoadedAsync(); + + /// Whether the initial load has finished. + bool IsLoaded { get; } +} + +/// +public sealed class ProxyPoolLoader(IProxyPool pool, ILogger logger) : IProxyPoolLoader +{ + private readonly IProxyPool _pool = pool ?? throw new ArgumentNullException(nameof(pool)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly Lock _gate = new(); + + private Task? _load; + + /// + public bool IsLoaded => _load is { IsCompleted: true }; + + /// + public Task EnsureLoadedAsync() + { + if (_load is { } started) + { + return started; + } + + lock (_gate) + { + // Deliberately not taking the caller's CancellationToken: the task is shared between + // the startup path and the proxy page, and one caller giving up must not cancel the + // load for the other. + _load ??= LoadAsync(); + return _load; + } + } + + private async Task LoadAsync() + { + try + { + // Worth logging: an empty pool is almost always a filter or a switched-off feed + // rather than a network problem, and without this it looks identical to both. + _logger.LogInformation( + "Loading proxy pool (feed: {UseFeed}, protocols: {Protocols})", + _pool.Options.UseFeed, + _pool.Options.Protocols + ); + + var count = await _pool.RefreshAsync(CancellationToken.None).ConfigureAwait(false); + _logger.LogInformation("Proxy pool loaded with {Count} entries", count); + return count; + } + catch (Exception ex) + { + // Startup must not fail because a public list is unreachable; the user can retry from + // the Proxies page, and the app works without proxies in the meantime. + _logger.LogWarning(ex, "Could not load the proxy pool at startup"); + return _pool.Entries.Count; + } + } +} diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs index cc632e5..0acf5a8 100644 --- a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using AvParser.Core.Parsing; using AvParser.Core.Proxies; using AvParser.Core.Settings; +using AvParser.Infrastructure.Proxies; using AvParser.Infrastructure.Storage; using AvParser.UI.Navigation; using AvParser.UI.Services; @@ -46,6 +47,7 @@ public static class UiServiceCollectionExtensions services.AddSingleton(static sp => new ProxiesViewModel( sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>() )); services.AddSingleton(); diff --git a/src/AvParser.UI/ViewModels/ProxiesViewModel.cs b/src/AvParser.UI/ViewModels/ProxiesViewModel.cs index 94f6ad9..518ae33 100644 --- a/src/AvParser.UI/ViewModels/ProxiesViewModel.cs +++ b/src/AvParser.UI/ViewModels/ProxiesViewModel.cs @@ -91,11 +91,13 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable /// Creates the page. /// The pool being managed. /// The user's editable list. + /// The shared startup load, joined so the page can report its outcome. /// Diagnostics. /// Scheduler for UI-affine updates; tests pass an immediate one. public ProxiesViewModel( IProxyPool pool, IMutableProxySource customSource, + IProxyPoolLoader loader, ILogger logger, ISequencer? mainThread = null ) @@ -104,6 +106,7 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable _customSource = customSource ?? throw new ArgumentNullException(nameof(customSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _mainThread = mainThread ?? RxSchedulers.MainThreadScheduler; + ArgumentNullException.ThrowIfNull(loader); SearchText = string.Empty; NewProxies = string.Empty; @@ -145,6 +148,11 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable RemoveSelectedCommand.ThrownExceptions.Subscribe(OnCommandFailed); Rebuild(); + + // The pool is normally already loading by the time this page is built, but joining the + // same operation means the page reports the outcome whether it is opened during the load + // or long after it. Not awaited — a constructor cannot be, and the task never throws. + _ = ReportInitialLoadAsync(loader); } /// @@ -185,6 +193,19 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable /// Empties the custom list. public ReactiveCommand ClearCustomCommand { get; } + private async Task ReportInitialLoadAsync(IProxyPoolLoader loader) + { + var count = await loader.EnsureLoadedAsync().ConfigureAwait(false); + var message = Localizer.Instance.Format( + "Proxies.Status.PoolHolds", + Localizer.Instance.Plural("Proxies.Count.Proxies", count) + ); + + // Anything the user has done since — an add, a check — is more interesting than the + // startup count, so do not overwrite it. + OnUi(() => StatusMessage ??= message); + } + private async Task RefreshAsync(CancellationToken cancellationToken) { var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false); diff --git a/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs b/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs new file mode 100644 index 0000000..0919135 --- /dev/null +++ b/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs @@ -0,0 +1,142 @@ +using AvParser.Core.Proxies; +using AvParser.Core.Settings; +using AvParser.Infrastructure.Settings; +using AvParser.Infrastructure.Storage; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AvParser.Infrastructure.Tests; + +public sealed class JsonSettingsServiceTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + "AvParserTests", + Guid.NewGuid().ToString("N") + ); + + private JsonSettingsService Create() => new(new AppPaths(_directory), NullLogger.Instance); + + private void WriteSettings(string json) + { + Directory.CreateDirectory(_directory); + File.WriteAllText(Path.Combine(_directory, "settings.json"), json); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + [Fact] + public void An_absent_file_yields_the_defaults() + { + using var service = Create(); + + service.Current.ShouldBe(new AppSettings()); + } + + /// + /// A settings file written before a setting existed must not zero that setting. + /// + /// + /// This is a regression test with a real bug behind it. Defaults used to live on property + /// initialisers, which the source-generated deserialiser does not run — so an older file came + /// back with default(T) everywhere, switching the proxy feed off, clearing the protocol + /// filter and zeroing the probe timeout. The app then loaded an empty pool and looked as if + /// the network had failed. + /// + [Fact] + public void A_file_from_an_older_version_keeps_the_defaults_for_settings_it_predates() + { + WriteSettings( + """ + { + "theme": "System", + "lastParserId": "delimited", + "windowWidth": 1280, + "windowHeight": 800, + "windowMaximized": false, + "minimumLogLevel": "Information" + } + """ + ); + + using var service = Create(); + var settings = service.Current; + + settings.ProxyUseFeed.ShouldBeTrue(); + settings.ProxyProtocols.ShouldBe(ProxyProtocolFilter.All); + settings.ProxyProbeTimeoutSeconds.ShouldBe(8); + settings.ProxyProbeConcurrency.ShouldBe(64); + settings.ProxyProbeUrl.ShouldNotBeNullOrEmpty(); + settings.Language.ShouldBe(AppLanguage.System); + + // And the values the file did carry must survive. + settings.LastParserId.ShouldBe("delimited"); + settings.MinimumLogLevel.ShouldBe("Information"); + } + + [Fact] + public void Options_built_from_an_older_file_still_consult_the_feed() + { + WriteSettings("""{ "theme": "Dark" }"""); + + using var service = Create(); + var options = service.Current.ToProxyOptions(); + + options.UseFeed.ShouldBeTrue(); + options.Protocols.ShouldBe(ProxyProtocolFilter.All); + } + + [Fact] + public void An_empty_protocol_filter_is_treated_as_all() + { + // Nothing in the UI can produce None, but a hand-edited file can — and a pool that + // silently matches nothing is the least useful possible reading of it. + var options = new AppSettings(ProxyProtocols: ProxyProtocolFilter.None).ToProxyOptions(); + + options.Protocols.ShouldBe(ProxyProtocolFilter.All); + } + + [Fact] + public void Out_of_range_values_are_clamped_rather_than_thrown() + { + var options = new AppSettings( + ProxyProbeTimeoutSeconds: 0, + ProxyProbeConcurrency: 100_000, + ProxyProbeUrl: "not a url" + ).ToProxyOptions(); + + options.ProbeTimeout.ShouldBe(TimeSpan.FromSeconds(1)); + options.ProbeConcurrency.ShouldBe(512); + options.ProbeUrl.ShouldBe(new ProxyOptions().ProbeUrl); + } + + [Fact] + public void A_corrupt_file_falls_back_to_the_defaults_instead_of_failing_to_start() + { + WriteSettings("{ this is not json"); + + using var service = Create(); + + service.Current.ShouldBe(new AppSettings()); + } + + [Fact] + public async Task Updates_round_trip_through_the_file() + { + using (var service = Create()) + { + service.Update(current => current with { Theme = AppTheme.Dark, ProxyUseFeed = false }); + await service.FlushAsync(TestContext.Current.CancellationToken); + } + + using var reopened = Create(); + + reopened.Current.Theme.ShouldBe(AppTheme.Dark); + reopened.Current.ProxyUseFeed.ShouldBeFalse(); + } +} diff --git a/tests/AvParser.Infrastructure.Tests/ProxyPoolLoaderTests.cs b/tests/AvParser.Infrastructure.Tests/ProxyPoolLoaderTests.cs new file mode 100644 index 0000000..f73a123 --- /dev/null +++ b/tests/AvParser.Infrastructure.Tests/ProxyPoolLoaderTests.cs @@ -0,0 +1,102 @@ +using AvParser.Core.Proxies; +using AvParser.Infrastructure.Proxies; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AvParser.Infrastructure.Tests; + +public class ProxyPoolLoaderTests +{ + private static ProxyPoolLoader Build(out CountingSource source, out ProxyPool pool) + { + source = new CountingSource(); + pool = new ProxyPool([source], new NeverProbe(), new ProxyOptions()); + + return new ProxyPoolLoader(pool, NullLogger.Instance); + } + + [Fact] + public async Task Loading_fills_the_pool() + { + var loader = Build(out _, out var pool); + + (await loader.EnsureLoadedAsync()).ShouldBe(2); + pool.Entries.Count.ShouldBe(2); + loader.IsLoaded.ShouldBeTrue(); + } + + [Fact] + public async Task The_feed_is_fetched_once_however_many_callers_there_are() + { + var loader = Build(out var source, out _); + + // Startup and the proxy page both ask; a second download of a 600 KB list would be waste. + await Task.WhenAll(loader.EnsureLoadedAsync(), loader.EnsureLoadedAsync(), loader.EnsureLoadedAsync()); + await loader.EnsureLoadedAsync(); + + source.Calls.ShouldBe(1); + } + + [Fact] + public async Task Concurrent_callers_share_one_operation() + { + var loader = Build(out _, out _); + + var first = loader.EnsureLoadedAsync(); + var second = loader.EnsureLoadedAsync(); + + first.ShouldBeSameAs(second); + await first; + } + + [Fact] + public async Task A_source_that_throws_does_not_take_startup_down() + { + var pool = new ProxyPool([new ThrowingSource()], new NeverProbe(), new ProxyOptions()); + var loader = new ProxyPoolLoader(pool, NullLogger.Instance); + + // The app has to start whether or not a public list is reachable. + (await loader.EnsureLoadedAsync()).ShouldBe(0); + } + + private sealed class CountingSource : IProxySource + { + public int Calls { get; private set; } + + public string Id => "counting"; + + public string DisplayName => "Counting source"; + + public ProxySourceKind Kind => ProxySourceKind.Feed; + + public Task> GetProxiesAsync(CancellationToken cancellationToken = default) + { + Calls++; + + return Task.FromResult>([ + new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080), + new ProxyEndpoint(ProxyProtocol.Socks5, "5.6.7.8", 1080), + ]); + } + } + + private sealed class ThrowingSource : IProxySource + { + public string Id => "throwing"; + + public string DisplayName => "Throwing source"; + + public ProxySourceKind Kind => ProxySourceKind.Feed; + + public Task> GetProxiesAsync(CancellationToken cancellationToken = default) => + throw new HttpRequestException("upstream is down"); + } + + private sealed class NeverProbe : IProxyProbe + { + public Task ProbeAsync( + ProxyEndpoint endpoint, + ProxyOptions options, + CancellationToken cancellationToken = default + ) => Task.FromResult(ProxyProbeResult.Failure("not used")); + } +} diff --git a/tests/AvParser.UI.HeadlessTests/ProxiesViewTests.cs b/tests/AvParser.UI.HeadlessTests/ProxiesViewTests.cs index 64367a8..2d6e103 100644 --- a/tests/AvParser.UI.HeadlessTests/ProxiesViewTests.cs +++ b/tests/AvParser.UI.HeadlessTests/ProxiesViewTests.cs @@ -3,6 +3,7 @@ using Avalonia.Headless.XUnit; using Avalonia.Threading; using Avalonia.VisualTree; using AvParser.Core.Proxies; +using AvParser.Infrastructure.Proxies; using AvParser.UI.ViewModels; using AvParser.UI.Views; using Microsoft.Extensions.Logging.Abstractions; @@ -23,6 +24,7 @@ public class ProxiesViewTests var viewModel = new ProxiesViewModel( pool, custom, + new ProxyPoolLoader(pool, NullLogger.Instance), NullLogger.Instance, ImmediateSequencer.Instance ); diff --git a/tests/AvParser.UI.Tests/ProxiesViewModelTests.cs b/tests/AvParser.UI.Tests/ProxiesViewModelTests.cs index f5502fe..9cede20 100644 --- a/tests/AvParser.UI.Tests/ProxiesViewModelTests.cs +++ b/tests/AvParser.UI.Tests/ProxiesViewModelTests.cs @@ -1,4 +1,5 @@ using AvParser.Core.Proxies; +using AvParser.Infrastructure.Proxies; using AvParser.UI.Tests.Fakes; using AvParser.UI.ViewModels; using Microsoft.Extensions.Logging.Abstractions; @@ -23,6 +24,7 @@ public class ProxiesViewModelTests var page = new ProxiesViewModel( pool, custom, + new ProxyPoolLoader(pool, NullLogger.Instance), NullLogger.Instance, ImmediateSequencer.Instance ); @@ -37,11 +39,40 @@ public class ProxiesViewModelTests private static Task Run(ReactiveUI.ReactiveCommand command) => command.Execute().ToTask(TestContext.Current.CancellationToken); + /// Polls until the throttled rebuild has caught up, or gives up. + /// + /// The page coalesces pool changes over 250 ms, so anything driven by the pool rather than by + /// a command needs to be waited for rather than asserted on the next line. + /// + private static async Task WaitUntil(Func condition) + { + for (var attempt = 0; attempt < 50 && !condition(); attempt++) + { + await Task.Delay(20, TestContext.Current.CancellationToken); + } + } + [Fact] - public void Starts_empty() + public async Task The_pool_loads_without_the_user_pressing_refresh() + { + var (page, _, _) = Build("a", "b"); + + // Deliberately no RefreshCommand: opening the page joins the startup load, which is what + // stops the list from being empty until someone presses the button. + await WaitUntil(() => page.TotalCount == 2); + + page.TotalCount.ShouldBe(2); + page.Proxies.Count.ShouldBe(2); + page.StatusMessage.ShouldNotBeNull(); + } + + [Fact] + public async Task An_empty_pool_stays_empty() { var (page, _, _) = Build(); + await WaitUntil(() => page.StatusMessage is not null); + page.Proxies.ShouldBeEmpty(); page.TotalCount.ShouldBe(0); } @@ -79,6 +110,7 @@ public class ProxiesViewModelTests var page = new ProxiesViewModel( pool, custom, + new ProxyPoolLoader(pool, NullLogger.Instance), NullLogger.Instance, ImmediateSequencer.Instance ); @@ -160,6 +192,7 @@ public class ProxiesViewModelTests var page = new ProxiesViewModel( pool, custom, + new ProxyPoolLoader(pool, NullLogger.Instance), NullLogger.Instance, ImmediateSequencer.Instance );