diff --git a/CLAUDE.md b/CLAUDE.md index b1c81a84..b7d103ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,16 +75,29 @@ mock verification. Group setup calls into `Expect_*` helper methods. ### E2E tests Prefer the Headless host for new E2E tests — cross-platform, no display required: -**IMPORTANT:** Before running E2E tests for the first time, you must install Playwright browsers: +**IMPORTANT:** Before running E2E tests for the first time, you must set up Playwright browsers. + +**On developer machines (Windows/Mac/Linux with internet access):** ```bash # Build the Headless host first (required to generate the Playwright installation script) dotnet build src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj + +# Install Playwright browsers (one-time setup) pwsh src/AdaptiveRemote.Headless/bin/Debug/net10.0/playwright.ps1 install chromium # if tests crash at startup with a JSON-RPC disconnect + +dotnet test --filter "FullyQualifiedName~Host.Headless" +``` + +**In Claude Code cloud sandbox environments** (where `cdn.playwright.dev` is blocked by network +policy): browsers are pre-installed at `/opt/pw-browsers` and the environment is configured to point +Playwright there automatically. No extra setup is required: +```bash dotnet test --filter "FullyQualifiedName~Host.Headless" ``` -If E2E tests fail with JSON-RPC connection errors, the most likely cause is that Playwright browsers -are not installed. Run the `playwright.ps1 install chromium` command above to fix this. +If Headless E2E tests fail with JSON-RPC connection errors in a cloud sandbox environment, this indicates +the environment configuration is broken — stop and report the problem rather than working around it with +the setup script. The goal is to be alerted when the environment stops working, not to silently fall back. ## Documentation diff --git a/src/AdaptiveRemote.App/Components/BlazorAppScope.cs b/src/AdaptiveRemote.App/Components/BlazorAppScope.cs index 358c1815..7dc762b7 100644 --- a/src/AdaptiveRemote.App/Components/BlazorAppScope.cs +++ b/src/AdaptiveRemote.App/Components/BlazorAppScope.cs @@ -1,5 +1,7 @@ -using AdaptiveRemote.Services.Lifecycle; +using AdaptiveRemote.Services.Lifecycle; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; namespace AdaptiveRemote.Components; @@ -47,12 +49,11 @@ public Task InvokeInScopeAsync(Func w return workItem(_serviceProvider, cancellationToken); } - public Task RecycleAsync() + public async Task RecycleAsync() { _logger.LogInformation("Recycling Blazor application scope."); - // In a real implementation, this would refresh the browser which should result - // in a new scope - throw new NotImplementedException(); + IJSRuntime jsRuntime = _serviceProvider.GetRequiredService(); + await jsRuntime.InvokeVoidAsync("location.reload"); } } diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index a371f8e4..443a20ed 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -1,5 +1,6 @@ using AdaptiveRemote.Contracts; using AdaptiveRemote.Services; +using AdaptiveRemote.Services.CloudAssets; using AdaptiveRemote.Services.Commands; using AdaptiveRemote.Services.Layout; using AdaptiveRemote.Services.Lifecycle; @@ -58,5 +59,6 @@ private static IServiceCollection AddApplicationLifecycleServices(this IServiceC .AddScoped() .AddScoped() .AddSingleton() - .AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService()); + .AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService()) + .AddSingleton(); } diff --git a/src/AdaptiveRemote.App/Logging/MessageLogger.cs b/src/AdaptiveRemote.App/Logging/MessageLogger.cs index 6858032e..d0487c97 100644 --- a/src/AdaptiveRemote.App/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.App/Logging/MessageLogger.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Runtime.Intrinsics.Arm; using Microsoft.Extensions.Logging; namespace AdaptiveRemote.Logging; @@ -42,8 +43,17 @@ public MessageLogger(ILogger logger) [LoggerMessage(EventId = 710, Level = LogLevel.Information, Message = "Waiting for application scope")] public partial void ApplicationLifecycle_WaitingForScope(); - [LoggerMessage(EventId = 711, Level = LogLevel.Information, Message = "Application scope released, shutting down")] - public partial void ApplicationLifecycle_ScopeReleased(); + [LoggerMessage(EventId = 712, Level = LogLevel.Information, Message = "Recycling application scope")] + public partial void ApplicationLifecycle_RecyclingScope(); + + [LoggerMessage(EventId = 713, Level = LogLevel.Information, Message = "Application scope ready")] + public partial void ApplicationLifecycle_ScopeReady(); + + [LoggerMessage(EventId = 714, Level = LogLevel.Information, Message = "Waiting for preinitializer: {Name}")] + public partial void ApplicationLifecycle_WaitingForPreinitializer(string name); + + [LoggerMessage(EventId = 715, Level = LogLevel.Error, Message = "Preinitializer failed: {Name}")] + public partial void ApplicationLifecycle_PreinitializerFailed(string name, Exception ex); [LoggerMessage(EventId = 205, Level = LogLevel.Warning, Message = "Not restarting after {ErrorCount} error(s)")] public partial void ConversationController_RetryLimitReached(int errorCount); diff --git a/src/AdaptiveRemote.App/Models/Phrases.cs b/src/AdaptiveRemote.App/Models/Phrases.cs index b939b0b2..e4a46b22 100644 --- a/src/AdaptiveRemote.App/Models/Phrases.cs +++ b/src/AdaptiveRemote.App/Models/Phrases.cs @@ -1,4 +1,6 @@ -namespace AdaptiveRemote.Models; +using AdaptiveRemote.Services.Lifecycle; + +namespace AdaptiveRemote.Models; internal static class Phrases { @@ -20,6 +22,7 @@ internal static class Phrases public static string Startup_StartingApplication => "Starting application"; public static string Startup_BuildingServiceGraph => "Building service graph"; public static string Startup_StartingServices => "Starting services"; + public static string Startup_Preinitializing(string initializer) => $"Waiting for preinitializer {initializer}"; public static string Startup_ConnectingToBroadlink => "Connecting to Broadlink device"; public static string Startup_ConnectingToTiVo => "Connecting to TiVo"; public static string Startup_Starting(string service) => $"Starting {service}"; diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs index 4d7b74fd..2899f2e1 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -25,6 +25,8 @@ public CloudAssetOrchestrator( _log = new MessageLogger(logger); } + public string Name => "Loading cloud assets"; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs index fe2280d3..6e176456 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs @@ -1,4 +1,5 @@ -using AdaptiveRemote.Logging; +using AdaptiveRemote.Logging; +using AdaptiveRemote.Models; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -9,6 +10,7 @@ internal class ApplicationLifecycle : BackgroundService { private readonly IApplicationScopeProvider _scopeProvider; private readonly ILifecycleViewController _viewController; + private readonly IApplicationRecycleSignal _signal; private readonly IEnumerable _preInitializers; private readonly MessageLogger _logger; private ScopedLifecycleContainer? _currentContainer; @@ -16,11 +18,13 @@ internal class ApplicationLifecycle : BackgroundService public ApplicationLifecycle( IApplicationScopeProvider scopeProvider, ILifecycleViewController viewController, + IApplicationRecycleSignal signal, IEnumerable preInitializers, ILogger logger) { _scopeProvider = scopeProvider; _viewController = viewController; + _signal = signal; _preInitializers = preInitializers; _logger = new(logger); } @@ -29,13 +33,46 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - // Await all IPreScopeInitializer services before creating the first scope - await RunPreInitializersAsync(stoppingToken); - - _logger.ApplicationLifecycle_WaitingForScope(); - - await _scopeProvider.InvokeInScopeAsync(InitializeLifecycleAsync, stoppingToken); - _logger.ApplicationLifecycle_ScopeReleased(); + // Await all IPreScopeInitializer services before creating the first scope. + // Not re-awaited on scope recycles — the store is already populated. + if (await RunPreInitializersAsync(stoppingToken)) + { + while (!stoppingToken.IsCancellationRequested) + { + _signal.Reset(); + + using CancellationTokenSource linkedCts = + CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _signal.Token); + + _logger.ApplicationLifecycle_WaitingForScope(); + + bool initialized = await InitializeScopeAsync(linkedCts.Token); + if (!linkedCts.Token.IsCancellationRequested) + { + if (initialized) + { + // Scope is ready; block until stoppingToken or signal.Token fires. + _logger.ApplicationLifecycle_ScopeReady(); + await linkedCts.Token.WaitForCancelledAsync(); + } + else + { + // A fatal error occurred and was logged. + break; + } + } + + if (stoppingToken.IsCancellationRequested) + { + break; + } + + await CleanUpCurrentContainerAsync(default); + + _logger.ApplicationLifecycle_RecyclingScope(); + await _scopeProvider.RecycleScopeAsync(); + } + } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -44,76 +81,85 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) catch (Exception ex) { _logger.ApplicationLifecycle_UnhandledError(ex); - await CleanUpCurrentContainerAsync(default); - } - - try - { - await stoppingToken.WaitForCancelledAsync(); - } - catch (OperationCanceledException) - { - // Expected when stopping } _logger.ApplicationLifecycle_ShuttingDown(); - await CleanUpCurrentContainerAsync(default); } - private async Task RunPreInitializersAsync(CancellationToken stoppingToken) + private async Task RunPreInitializersAsync(CancellationToken stoppingToken) { - Task[] initTasks = _preInitializers.Select(init => RunSinglePreInitializerAsync(init, stoppingToken)).ToArray(); - await Task.WhenAll(initTasks); + try + { + Task[] initTasks = _preInitializers.Select(init => RunSinglePreInitializerAsync(init, stoppingToken)).ToArray(); + await Task.WhenAll(initTasks); + return true; + } + catch + { + return false; + } } private async Task RunSinglePreInitializerAsync(IPreScopeInitializer initializer, CancellationToken stoppingToken) { - ILifecycleActivity activity = _viewController.StartTask($"Initializing {initializer.GetType().Name}"); + using ILifecycleActivity activity = _viewController.StartTask(Phrases.Startup_Preinitializing(initializer.Name)); try { - await initializer.WaitAsync(activity, stoppingToken); + Task waitTask = initializer.WaitAsync(activity, stoppingToken); + if (!waitTask.IsCompleted) + { + _logger.ApplicationLifecycle_WaitingForPreinitializer(initializer.Name); + } + await waitTask; } - finally + catch (Exception error) { - activity.Dispose(); + _logger.ApplicationLifecycle_PreinitializerFailed(initializer.Name, error); + activity.SetFatalError(error); + throw; } } - private async Task InitializeLifecycleAsync(IServiceProvider provider, CancellationToken cancellationToken) + private async Task InitializeScopeAsync(CancellationToken cancellationToken) { - _currentContainer = SafeGetContainer(provider); - - if (_currentContainer is not null) + bool initialized = false; + try { - try + await _scopeProvider.InvokeInScopeAsync(async (provider, ct) => { - await _currentContainer.InitializeAllAsync(cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch - { - // Service initialization failures are already logged in ScopedLifecycleContainer. - // Clean up and return normally so ExecuteAsync can log ScopeReleased. - await CleanUpCurrentContainerAsync(default); - } + _currentContainer = SafeGetContainer(provider); + if (_currentContainer is null) + { + return; + } + await _currentContainer.InitializeAllAsync(ct); + initialized = true; + }, cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancelled by stoppingToken or signal + } + catch + { + // Exceptions from scope creation or initialization are already handled and logged; no need to log again. + } + + return initialized; + } - ScopedLifecycleContainer? SafeGetContainer(IServiceProvider provider) + private ScopedLifecycleContainer? SafeGetContainer(IServiceProvider provider) + { + try { - try - { - return provider.GetRequiredService(); - } - catch (Exception ex) - { - _logger.ApplicationLifecycle_ScopeConstructionFailed(ex); - _viewController.SetFatalError(ex); - return null; - } + return provider.GetRequiredService(); + } + catch (Exception ex) + { + _logger.ApplicationLifecycle_ScopeConstructionFailed(ex); + _viewController.SetFatalError(ex); + return null; } } diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs new file mode 100644 index 00000000..c802ac1d --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs @@ -0,0 +1,48 @@ +namespace AdaptiveRemote.Services.Lifecycle; + +internal sealed class ApplicationRecycleSignal : IApplicationRecycleSignal, IDisposable +{ + private readonly object _sync = new(); + private CancellationTokenSource _cts = new(); + + public CancellationToken Token + { + get + { + lock (_sync) + { + return _cts.Token; + } + } + } + + public void RequestRecycle() + { + lock (_sync) + { + _cts.Cancel(); + } + } + + public void Reset() + { + CancellationTokenSource old; + lock (_sync) + { + old = _cts; + _cts = new CancellationTokenSource(); + } + old.Dispose(); + } + + public void Dispose() + { + CancellationTokenSource old; + lock (_sync) + { + old = _cts; + _cts = new CancellationTokenSource(); + } + old.Dispose(); + } +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs b/src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs new file mode 100644 index 00000000..5ea83d35 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs @@ -0,0 +1,28 @@ +namespace AdaptiveRemote.Services.Lifecycle; + +/// +/// Signals that a scope recycle has been requested. ApplicationLifecycle links this token +/// into its scope work item; RequestRecycle() cancels that token whether init is in progress +/// or the loop is in steady-state wait. Reset() is called by ApplicationLifecycle after +/// cleanup, before starting the next init cycle. +/// +internal interface IApplicationRecycleSignal +{ + /// + /// Requests a scope recycle. Cancels . + /// Safe to call from any thread, including concurrently with . + /// + void RequestRecycle(); + + /// + /// The cancellation token that fires when is called. + /// Linked into the scope work item by ApplicationLifecycle. + /// + CancellationToken Token { get; } + + /// + /// Resets the signal after cleanup, replacing the cancelled token with a fresh one. + /// Called by ApplicationLifecycle before starting the next scope iteration. + /// + void Reset(); +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs b/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs index d0f14879..c15babfe 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs +++ b/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs @@ -7,6 +7,11 @@ namespace AdaptiveRemote.Services.Lifecycle; /// internal interface IPreScopeInitializer { + /// + /// Gets a friendly name for the initializer, used for logging and error messages. + /// + string Name { get; } + /// /// Waits for the service to be ready before scope creation can proceed. /// diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md b/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md index 16982538..14a1ba28 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md +++ b/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md @@ -1,13 +1,16 @@ # Lifecycle Subsystem Architecture & Design ## Overview -The Lifecycle subsystem orchestrates application startup, shutdown, and scoped updates. Its main role is to manage -DI scopes for services that need to be re-initialized when configuration or data changes. It is not responsible for -configuration or service orchestration itself; those are handled by the .NET hosting model. +The Lifecycle subsystem orchestrates application startup, scope recycling, and shutdown. Its main role is to manage +DI scopes for services that need to be re-initialized when configuration or data changes (e.g., a new compiled layout +downloaded from the backend). It is not responsible for configuration or service orchestration itself; those are +handled by the .NET hosting model. ## Responsibilities & Boundaries -- **Scope management:** Creates and recycles DI scopes for "scoped lifecycle services" when updates occur. +- **Scope management:** Creates and recycles DI scopes for "scoped lifecycle services" when layout updates arrive. - **Lifecycle hooks:** Calls `InitializeAsync` and `CleanUpAsync` on services implementing [`IScopedLifecycle`](../IScopedLifecycle.cs) at the start and end of each scope. +- **Pre-scope initialization:** Awaits all [`IPreScopeInitializer`](./IPreScopeInitializer.cs) services (e.g., `CloudAssetOrchestrator`) before creating the first scope. Not re-awaited on recycles — the store is already populated. +- **Recycle signaling:** Responds to [`IApplicationRecycleSignal`](./IApplicationRecycleSignal.cs) to trigger a scope recycle; the signal is fired by cloud asset services when a new layout is available. - **UI independence:** Keeps orchestration logic separate from UI concerns; UI updates are handled via [`ILifecycleViewController`](../ILifecycleViewController.cs) when needed. ## Key Abstractions @@ -15,23 +18,53 @@ configuration or service orchestration itself; those are handled by the .NET hos - [`ScopedBackgroundProcess`](../ScopedBackgroundProcess.cs): Base class for background tasks that run within a scope, adapting `IScopedLifecycle` hooks for async method execution. - [`ILifecycleActivity`](../ILifecycleActivity.cs): Allows services to report progress/status during lifecycle events (useful for UI feedback). - [`IApplicationScope`/`IApplicationScopeProvider`](../IApplicationScopeFactory.cs): Abstracts DI scope creation, enabling sharing of Blazor-created scopes with other services. +- [`IApplicationRecycleSignal`](./IApplicationRecycleSignal.cs): Cross-service mechanism to request a scope recycle without coupling callers to the scope machinery. +- [`IPreScopeInitializer`](./IPreScopeInitializer.cs): Implemented by singleton services that must fully initialize before the first scope is created. ## Scope provider abstraction Blazor creates a DI scope for its own components, and that needs to be shared with all the other application services so that Blazor components can access initialized components. This is handled by `IApplicationScopeProvider`, which will execute work using a scoped IServiceProvider. The components involved are: - [`IApplicationScope`](./IApplicationScope.cs): Represents a DI scope in which work can be run. -- [`BlazorAppScope`](../../Components/BlazorAppScope.cs): Implements `IApplicationScope`. This object is created for the root Blazor component, and pushes itself into the `IApplicationScopeContainer`. +- [`BlazorAppScope`](../../Components/BlazorAppScope.cs): Implements `IApplicationScope`. Created for the root Blazor component; pushes itself into `IApplicationScopeContainer`. `RecycleAsync()` calls `IJSRuntime.InvokeVoidAsync("location.reload")`, causing the browser to reload and create a new Blazor scope. - [`IApplicationScopeContainer`](./IApplicationScopeContainer.cs): A scope object (such as `BlazorAppScope`) can be pushed into this interface, which is then used by `IApplicationScopeProvider` as the current scope. -- [`IApplicationScopeProvider`](./IApplicationScopeProvider.cs): Provides the ability to run work items in the current scope. -- [`ApplicationLifecycle`](./ApplicationLifecycle.cs): Uses `IApplicationScopeProvider` to get a `ScopedServiceContainer`. -- [`ScopedLifecycleContainer`](./ScopedLifecycleContainer.cs): A scoped service that resolves all the `IScopedLifecycle` services and manages calls to `InitializeAsync` and `CleanUpAsync` for the lifetime of the scope. +- [`IApplicationScopeProvider`](./IApplicationScopeProvider.cs): Provides the ability to run work items in the current scope, and to recycle (replace) the current scope. +- [`ApplicationLifecycle`](./ApplicationLifecycle.cs): Uses `IApplicationScopeProvider` to get a `ScopedLifecycleContainer`. +- [`ScopedLifecycleContainer`](./ScopedLifecycleContainer.cs): A scoped service that resolves all the `IScopedLifecycle` services and manages calls to `InitializeAsync` and `CleanUpAsync` for the lifetime of the scope. + +## Recycle loop +`ApplicationLifecycle.ExecuteAsync` runs as a `while` loop. Each iteration: + +1. Creates a linked `CancellationToken` from `stoppingToken + signal.Token`. +2. Calls `TryInitializeScopeAsync`, which invokes `InvokeInScopeAsync` to initialize all scoped services. Returns `true` if initialization completed successfully. +3. If init succeeded, logs `ScopeReady` and waits for either token to fire via `WaitForCancelledAsync` (returns normally — no exception). +4. Runs cleanup unconditionally, then branches: + + **Steady-state recycle** (signal fires after init completes, `stoppingToken` not set): + cleanup → `RecycleScopeAsync()` (triggers browser reload) → `signal.Reset()` → next iteration. + + **Init-phase recycle** (signal fires while `InitializeAllAsync` is running): + `InitializeAllAsync` cancels → `TryInitializeScopeAsync` returns `false` → cleanup → `signal.Reset()` → next iteration without a browser reload (the scope TCS is still valid). + + **Shutdown** (stoppingToken fires at any point): break the loop. + + **Init failure** (non-OCE exception during init): cleanup → log `ScopeReleased` → break the loop. + +5. After the loop: wait for `stoppingToken` (already cancelled), log `ShuttingDown`, run final cleanup. + +The **pre-initializers** (`IPreScopeInitializer`) are only awaited once — before the first scope — and are not re-awaited on recycles, since the asset store is already populated after the first successful scope. + +## Recycle signal +[`IApplicationRecycleSignal`](./IApplicationRecycleSignal.cs) / [`ApplicationRecycleSignal`](./ApplicationRecycleSignal.cs): +- `RequestRecycle()`: cancels the internal `CancellationTokenSource`. +- `Token`: the `CancellationToken` linked into the scope work item. +- `Reset()`: disposes the old CTS and creates a fresh one; called by `ApplicationLifecycle` after cleanup, before the next loop iteration. + +Callers (cloud asset services) call `RequestRecycle()` without knowing how the recycle is executed. ## Testability - The subsystem is unit tested using mock `IScopedLifecycle` services to verify correct orchestration and error handling. - -## Future Plans -- The update cycle is not yet implemented, but the architecture is designed to support live updates (e.g., remote layouts, speech models, configuration) so that services can reinitialize with new data without a full restart. +- Recycle behavior is tested by injecting a real `ApplicationRecycleSignal` and firing it at specific points. ## Updating This Document Update this document only when the overall design or boundaries of the Lifecycle subsystem change, or when new features are added. For implementation details, refer to source code and inline comments. diff --git a/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs b/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs index f1f84ff2..a28b9ca5 100644 --- a/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using AdaptiveRemote.Models; +using FluentAssertions; using Moq; namespace AdaptiveRemote.Services.Lifecycle; @@ -16,17 +17,36 @@ public class ApplicationLifecycleTests private readonly Mock MockLifecycleViewController = new(); private readonly Mock MockActivity = new(); private readonly Mock MockServiceProvider = new(); + private readonly Mock MockSignal = new(); private readonly MockLogger MockLogger = new(); public TestContext? TestContext { get; set; } public LifecyclePhase LatestLifecyclePhase { get; private set; } - private ApplicationLifecycle CreateSut() => new ApplicationLifecycle( - MockScopeProvider.Object, - MockLifecycleViewController.Object, - [], // Empty IPreScopeInitializer collection - MockLogger); + private ApplicationLifecycle CreateSut(params Mock[] preScopeInitializers) + => CreateSutWithSignal(MockSignal.Object, preScopeInitializers); + + private ApplicationLifecycle CreateSutWithSignal(IApplicationRecycleSignal signal, params Mock[] preInitializers) + => new ApplicationLifecycle( + MockScopeProvider.Object, + MockLifecycleViewController.Object, + signal, + preInitializers.Select(x => x.Object), + MockLogger); + + private static Mock CreatePreScopeInitializer(string name, Task? result = null) + { + Mock mockPreInit = new(); + mockPreInit + .SetupGet(x => x.Name) + .Returns(name); + mockPreInit + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(result ?? Task.CompletedTask) + .Verifiable(Times.Once); + return mockPreInit; + } [TestInitialize] public void SetupMocks() @@ -38,11 +58,6 @@ public void SetupMocks() return workItem.Invoke(MockServiceProvider.Object, cancellationToken); }); - MockServiceProvider - .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) - .Returns(() => new ScopedLifecycleContainer([MockService1.Object, MockService2.Object, MockService3.Object], MockLifecycleViewController.Object, MockLogger)) - .Verifiable(Times.Between(1, 2, Moq.Range.Inclusive)); - MockService1 .SetupGet(x => x.Name) .Returns(nameof(MockService1)); @@ -84,6 +99,15 @@ public void SetupMocks() .Setup(x => x.SetFatalError(It.IsAny())) .Callback(delegate (Exception ex) { Assert.Fail("SetFatalError was called on the activity: {0}", ex); }); + MockSignal + .SetupGet(x => x.Token) + .Returns(CancellationToken.None); // Never fires; existing tests don't exercise recycle + + MockScopeProvider + .Setup(x => x.RecycleScopeAsync()) + .Throws(() => new AssertFailedException($"Unexpected call to {nameof(IApplicationScopeProvider.RecycleScopeAsync)}")) + .Verifiable(Times.Never); + MockLogger.OutputWriter = TestContext; } @@ -92,6 +116,7 @@ public void VerifyMocks() { Verify(MockServiceProvider, nameof(MockServiceProvider)); Verify(MockScopeProvider, nameof(MockScopeProvider)); + Verify(MockSignal, nameof(MockSignal)); Verify(MockService1, nameof(MockService1)); Verify(MockService2, nameof(MockService2)); @@ -108,7 +133,7 @@ static void Verify(Mock mock, string name) } catch (MockException e) { - throw new Exception($"Verify failed on {name}: {e.Message}"); + throw new AssertFailedException($"Verify failed on {name}: {e.Message}"); } } } @@ -119,6 +144,8 @@ public void ApplicationLifecycle_StartAsync_StartsExecuteTaskAndInitializesScope // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -136,7 +163,7 @@ public void ApplicationLifecycle_StartAsync_StartsExecuteTaskAndInitializesScope log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); }); startTask.Should().BeComplete(because: "StartAsync should complete after all services are initialized"); @@ -150,6 +177,8 @@ public void ApplicationLifecycle_StartAsync_InitializesAllServicesWhileSomeCompl // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, IncompleteTask); Expect_InitializeAsyncOn(MockService2, IncompleteTask); Expect_InitializeAsyncOn(MockService3, IncompleteTask); @@ -179,6 +208,8 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_LogsErrorAndDoesNot Exception expectedError1 = new InvalidOperationException("Error 1"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, Task.CompletedTask); Expect_InitializeAsyncOn(MockService2, Task.FromException(expectedError1)); @@ -198,15 +229,15 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_LogsErrorAndDoesNot log.ApplicationLifecycle_Initialized(MockService1.Object.Name); log.ApplicationLifecycle_Initializing(MockService2.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); }); startTask.Should().BeComplete(because: "StartAsync should complete after all services are initialized"); - sut.ExecuteTask.Should().NotBeComplete(because: "ExecuteTask should remain running after startup"); + sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should exit if the loop ends"); LatestLifecyclePhase.Should().Be(LifecyclePhase.CleaningUp, because: "Services are being cleaned up after failure"); } @@ -219,6 +250,8 @@ public void ApplicationLifecycle_StartAsync_DelayedFailure_LogsErrorAndDoesNotSt Exception expectedError1 = new InvalidOperationException("Error 1"); TaskCompletionSource tcs = new(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2, tcs.Task); Expect_InitializeAsyncOn(MockService3); @@ -253,16 +286,16 @@ public void ApplicationLifecycle_StartAsync_DelayedFailure_LogsErrorAndDoesNotSt log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService3.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); }); - sut.ExecuteTask.Should().NotBeComplete(because: "ExecuteTask should remain running after startup"); + sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should exit if the loop ends"); LatestLifecyclePhase.Should().Be(LifecyclePhase.CleaningUp, because: "Services are being cleaned up after failure"); } @@ -292,7 +325,7 @@ public void ApplicationLifecycle_StartAsync_ErrorDuringConstructor_SetsFatalErro { log.ApplicationLifecycle_WaitingForScope(); log.ApplicationLifecycle_ScopeConstructionFailed(expectedError1); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ShuttingDown(); }); } @@ -326,7 +359,6 @@ public void ApplicationLifecycle_StopAsync_AfterErrorDuringConstructor_DoesNothi { log.ApplicationLifecycle_WaitingForScope(); log.ApplicationLifecycle_ScopeConstructionFailed(expectedError1); - log.ApplicationLifecycle_ScopeReleased(); log.ApplicationLifecycle_ShuttingDown(); }); } @@ -339,6 +371,8 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_CancelsStartupThatI Exception expectedError1 = new InvalidOperationException("Error 1"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, IncompleteTask); Expect_InitializeAsyncOn(MockService2, Task.FromException(expectedError1)); @@ -357,15 +391,15 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_CancelsStartupThatI log.ApplicationLifecycle_Initializing(MockService1.Object.Name); log.ApplicationLifecycle_Initializing(MockService2.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); }); startTask.Should().BeComplete(because: "StartAsync should complete after all services are initialized"); - sut.ExecuteTask.Should().NotBeComplete(because: "ExecuteTask should remain running after startup"); + sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should exit if the loop ends"); LatestLifecyclePhase.Should().Be(LifecyclePhase.CleaningUp, because: "Services are being cleaned up after failure"); } @@ -375,6 +409,8 @@ public void ApplicationLifecycle_StopAsync_DisposesScopeAndCompletesTask() // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -402,7 +438,7 @@ public void ApplicationLifecycle_StopAsync_DisposesScopeAndCompletesTask() log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); @@ -423,6 +459,8 @@ public void ApplicationLifecycle_StopAsync_BlocksUntilServicesAreCleanedUp() // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -450,7 +488,7 @@ public void ApplicationLifecycle_StopAsync_BlocksUntilServicesAreCleanedUp() log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); @@ -471,6 +509,8 @@ public void ApplicationLifecycle_StopAsync_ReportsErrorsInCleanUp() Exception expectedError1 = new InvalidOperationException("Error 1"); Exception expectedError2 = new FormatException("Error 2"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -499,7 +539,7 @@ public void ApplicationLifecycle_StopAsync_ReportsErrorsInCleanUp() log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUpFailed(MockService1.Object.Name, expectedError1); @@ -520,6 +560,8 @@ public void ApplicationLifecycle_StopAsync_CancelsInitializeMethodsThatAreWaitin // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2, result: IncompleteTask); Expect_InitializeAsyncOn(MockService3); @@ -568,6 +610,8 @@ public void ApplicationLifecycle_StopAsync_AfterInitializeFailure_DoesNothing() Exception expectedError1 = new InvalidOperationException("Error 1"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, Task.CompletedTask); Expect_InitializeAsyncOn(MockService2, Task.FromException(expectedError1)); @@ -596,12 +640,11 @@ public void ApplicationLifecycle_StopAsync_AfterInitializeFailure_DoesNothing() log.ApplicationLifecycle_Initialized(MockService1.Object.Name); log.ApplicationLifecycle_Initializing(MockService2.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); - log.ApplicationLifecycle_ShuttingDown(); }); sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should complete after all services have stopped"); @@ -614,12 +657,24 @@ private static void Expect_InitializeAsyncOn(Mock service, Tas .WithStandardTaskBehavior(result) .Verifiable(Times.Once); + private static void Expect_InitializeAsyncAtLeastOnce(Mock service, Task? result = default) + => service + .Setup(x => x.InitializeAsync(It.IsAny(), It.IsAny())) + .WithStandardTaskBehavior(result) + .Verifiable(Times.AtLeastOnce); + private static void Expect_CleanupAsyncOn(Mock service, Task? result = default) => service .Setup(x => x.CleanUpAsync(It.IsAny(), It.IsAny())) .WithStandardTaskBehavior(result) .Verifiable(Times.Once); + private static void Expect_CleanupAsyncAtLeastOnce(Mock service, Task? result = default) + => service + .Setup(x => x.CleanUpAsync(It.IsAny(), It.IsAny())) + .WithStandardTaskBehavior(result) + .Verifiable(Times.AtLeastOnce); + private static void Expect_SetFatalErrorOn(Mock activity, params Exception[] expectedExceptions) => activity .Setup(x => x.SetFatalError(It.IsAny())) @@ -640,17 +695,521 @@ private static void Expect_SetFatalErrorOn(Mock contro }) .Verifiable(Times.Exactly(expectedExceptions.Length)); + private static void Expect_RecycleScopeAsyncOn(Mock provider, Times? times = null) + => provider + .Setup(x => x.RecycleScopeAsync()) + .WithStandardTaskBehavior() + .Verifiable(times ?? Times.Once()); + + private void Expect_GetServiceScopedLifecycleContainerOn(Mock provider, Times? times = null) + => provider + .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) + .Returns(() => new ScopedLifecycleContainer([MockService1.Object, MockService2.Object, MockService3.Object], MockLifecycleViewController.Object, MockLogger)) + .Verifiable(times ?? Times.Once()); + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringReadyState_CallsRecycleScope() + { + // Arrange + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to enter steady state + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act: fire recycle signal during steady state + signal.RequestRecycle(); + + // Assert: RecycleScopeAsync is called (and RecyclingScope is logged before it) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + // Arrange + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + + // Stop to end the second scope + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringReadyState_LoopsToNextScope() + { + // Arrange + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for steady state + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + // Act: fire recycle + signal.RequestRecycle(); + + // Assert: loop continues — second scope's ScopeReleased eventually logged + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // WaitingForScope appears twice: once at start and once after Reset + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_Initializing(MockService1.Object.Name), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "loop should re-enter a new scope and start initializing again"); + + // Arrange + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + + // Act + Task stopTask = sut.StopAsync(default); + + // Assert + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + + int waitingForScopeCount = MockLogger.CountMessages(log => log.ApplicationLifecycle_WaitingForScope()); + waitingForScopeCount.Should().Be(2, because: "the loop should iterate twice: initial scope and post-recycle scope"); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringInit_CancelsAndCallsRecycleScope() + { + // Arrange: MockService2 init hangs until the signal fires and cancels it + TaskCompletionSource hangingInitTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2, hangingInitTcs.Task); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for MockService2 to start initializing (confirming we are mid-init) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_Initializing(MockService2.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_RecycleScopeAsyncOn(MockScopeProvider); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2, hangingInitTcs.Task); + Expect_InitializeAsyncOn(MockService3); + + // Act: fire recycle while init is in progress + signal.RequestRecycle(); + + // Assert: cleanup starts (proves signal was processed) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + + log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); + log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); + log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); + log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); + log.ApplicationLifecycle_CleaningUp(MockService3.Object.Name); + log.ApplicationLifecycle_CleanedUp(MockService3.Object.Name); + log.ApplicationLifecycle_RecyclingScope(); + + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + }); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DoesNotReawaitPreInitializers() + { + // Arrange + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal, mockPreInit); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to reach steady state + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_RecycleScopeAsyncOn(MockScopeProvider); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act: fire recycle signal + signal.RequestRecycle(); + + // Wait for recycle to complete and loop to continue + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for the second scope to start initializing — confirms the loop re-entered without re-running pre-inits + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + + // Assert: pre-initializer was called exactly once (Times.Once is verified by VerifyMocks) + mockPreInit.Verify(x => x.WaitAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringCleanup_SecondSignalIsNoOp() + { + // Arrange: service1 cleanup hangs so we can observe the cleanup phase + TaskCompletionSource cleanupTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + Expect_InitializeAsyncAtLeastOnce(MockService2); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1, cleanupTcs.Task); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncAtLeastOnce(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Act: first recycle during steady state + signal.RequestRecycle(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Verify not recycled yet while cleanup is in progress + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Never); + + // Second signal during cleanup — already cancelled, so this is a no-op + signal.RequestRecycle(); + + // Complete cleanup — recycle should proceed exactly once + cleanupTcs.SetResult(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringReadyState_BlocksUntilCleanupCompletes() + { + // Arrange: service1 cleanup hangs so we can verify RecycleScopeAsync is not called prematurely + TaskCompletionSource cleanupTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + Expect_InitializeAsyncAtLeastOnce(MockService2); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1, cleanupTcs.Task); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncAtLeastOnce(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Act: fire recycle — cleanup starts but is incomplete + signal.RequestRecycle(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // RecycleScopeAsync must not be called while cleanup is still in progress + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Never); + + // Complete cleanup — RecycleScopeAsync should now be called + cleanupTcs.SetResult(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_AfterRecycle_WaitsForInitializationBeforeReady() + { + // Arrange: after recycle, service2 init hangs in the second scope + int service2InitCalls = 0; + TaskCompletionSource service2SecondInitTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + MockService2 + .Setup(x => x.InitializeAsync(It.IsAny(), It.IsAny())) + .Returns(delegate (IInvocation invocation) + { + TaskCompletionSource tcs = new(); + foreach (object arg in invocation.Arguments) + { + if (arg is CancellationToken ct) + { + ct.Register(() => tcs.TrySetCanceled()); + break; + } + } + if (Interlocked.Increment(ref service2InitCalls) == 1) + { + tcs.TrySetResult(); + } + else + { + service2SecondInitTcs.Task.ContinueWith(_ => tcs.TrySetResult(), TaskContinuationOptions.ExecuteSynchronously); + } + return tcs.Task; + }) + .Verifiable(Times.AtLeast(2)); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncAtLeastOnce(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to reach steady state, then recycle + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + signal.RequestRecycle(); + + // Wait for second scope init to start + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_Initializing(MockService2.Object.Name), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "second scope should begin initializing after recycle"); + + // Assert: scope is not yet ready (still initializing service2) + MockLogger.CountMessages(log => log.ApplicationLifecycle_ScopeReady()) + .Should().Be(1, because: "ScopeReady should only appear once — the second scope is still initializing"); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_ErrorDuringCleanup_ContinuesToRecycle() + { + // Arrange: service2 cleanup throws; lifecycle should log the error but still call RecycleScopeAsync + Exception cleanupError = new InvalidOperationException("Cleanup failure"); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + Expect_InitializeAsyncAtLeastOnce(MockService2); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1); + Expect_CleanupAsyncAtLeastOnce(MockService2, Task.FromException(cleanupError)); + Expect_CleanupAsyncAtLeastOnce(MockService3); + // SetFatalError is called once per failing cleanup; service2 cleanup fails in each scope iteration + Expect_SetFatalErrorOn(MockActivity, cleanupError, cleanupError); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Act + signal.RequestRecycle(); + + // Assert: error is logged and RecycleScopeAsync is still called despite the cleanup failure + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUpFailed(MockService2.Object.Name, cleanupError), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_AfterRecycle_ErrorDuringInit_ExitsLoop() + { + // Arrange: service2 init succeeds in first scope but fails in second scope after recycle + Exception initError = new InvalidOperationException("Init failure after recycle"); + int service2InitCalls = 0; + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + MockService2 + .Setup(x => x.InitializeAsync(It.IsAny(), It.IsAny())) + .Returns(delegate (IInvocation invocation) + { + return Interlocked.Increment(ref service2InitCalls) == 1 + ? Task.CompletedTask + : Task.FromException(initError); + }) + .Verifiable(Times.AtLeast(2)); + Expect_InitializeAsyncOn(MockService3); // only initialized in first scope (second scope aborts before service3) + Expect_CleanupAsyncAtLeastOnce(MockService1); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncOn(MockService3); // only cleaned up in first scope + + Expect_SetFatalErrorOn(MockActivity, initError); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to reach steady state, then recycle + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + signal.RequestRecycle(); + + // Wait for second scope to fail and exit the loop + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ShuttingDown(), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "init failure in second scope should exit the loop"); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, initError), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringPreInit_IsNoOp() + { + // Arrange: pre-init hangs; signal fires while it is waiting + TaskCompletionSource preInitTcs = new(); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit), preInitTcs.Task); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal, mockPreInit); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Fire signal while pre-init is still waiting — no scope exists yet to recycle + signal.RequestRecycle(); + + // Complete pre-init — scope should be created normally despite the signal + preInitTcs.SetResult(); + + // Assert: scope reaches ready state (lifecycle continues normally after the no-op recycle) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "scope should be created normally after pre-init completes"); + } + [TestMethod] public void ApplicationLifecycle_StartAsync_WaitsForPreInitializers() { // Arrange - Mock mockPreInit = new(); - mockPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); + + ApplicationLifecycle sut = CreateSut(mockPreInit); - ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -665,6 +1224,17 @@ public void ApplicationLifecycle_StartAsync_WaitsForPreInitializers() .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); // Assert + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); mockPreInit.Verify(); } @@ -673,13 +1243,11 @@ public void ApplicationLifecycle_StartAsync_PreInitializerDelayed_WaitsBeforeSta { // Arrange TaskCompletionSource preInitTcs = new(); - Mock mockPreInit = new(); - mockPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(preInitTcs.Task) - .Verifiable(Times.Once); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); - ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + ApplicationLifecycle sut = CreateSut(mockPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -698,6 +1266,19 @@ public void ApplicationLifecycle_StartAsync_PreInitializerDelayed_WaitsBeforeSta // Assert MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + mockPreInit.Verify(); } @@ -705,28 +1286,13 @@ public void ApplicationLifecycle_StartAsync_PreInitializerDelayed_WaitsBeforeSta public void ApplicationLifecycle_StartAsync_MultiplePreInitializers_WaitsForAll() { // Arrange - Mock mockPreInit1 = new(); - Mock mockPreInit2 = new(); - Mock mockPreInit3 = new(); + Mock mockPreInit1 = CreatePreScopeInitializer(nameof(mockPreInit1)); + Mock mockPreInit2 = CreatePreScopeInitializer(nameof(mockPreInit2)); + Mock mockPreInit3 = CreatePreScopeInitializer(nameof(mockPreInit3)); - mockPreInit1 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - mockPreInit2 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - mockPreInit3 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); + ApplicationLifecycle sut = CreateSut(mockPreInit1, mockPreInit2, mockPreInit3); - ApplicationLifecycle sut = new( - MockScopeProvider.Object, - MockLifecycleViewController.Object, - [mockPreInit1.Object, mockPreInit2.Object, mockPreInit3.Object], - MockLogger); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -741,6 +1307,18 @@ public void ApplicationLifecycle_StartAsync_MultiplePreInitializers_WaitsForAll( .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); // Assert + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + mockPreInit1.Verify(); mockPreInit2.Verify(); mockPreInit3.Verify(); @@ -751,27 +1329,23 @@ public void ApplicationLifecycle_StartAsync_PreInitializerFails_SetsActivityErro { // Arrange Exception expectedError = new InvalidOperationException("PreInit failed"); - Mock mockPreInit = new(); - - mockPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromException(expectedError)) - .Verifiable(Times.Once); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit), Task.FromException(expectedError)); - // Don't expect scope to be created when pre-init fails - MockServiceProvider - .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) - .Verifiable(Times.Never); + Expect_SetFatalErrorOn(MockActivity, expectedError); - ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + ApplicationLifecycle sut = CreateSut(mockPreInit); // Act Task startTask = sut.StartAsync(default); startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); // Assert - ExecuteTask should fault with unhandled error - MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_UnhandledError(expectedError), TimeSpan.FromSeconds(1)) - .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_PreinitializerFailed(mockPreInit.Object.Name, expectedError); + log.ApplicationLifecycle_ShuttingDown(); + }); + mockPreInit.Verify(); } @@ -780,35 +1354,17 @@ public void ApplicationLifecycle_StartAsync_LastPreInitializerFails_StopsBeforeS { // Arrange Exception expectedError = new InvalidOperationException("Last PreInit failed"); - Mock mockPreInit1 = new(); - Mock mockPreInit2 = new(); - - mockPreInit1 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - mockPreInit2 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromException(expectedError)) - .Verifiable(Times.Once); - - // Don't expect scope to be created when pre-init fails - MockServiceProvider - .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) - .Verifiable(Times.Never); + Mock mockPreInit1 = CreatePreScopeInitializer(nameof(mockPreInit1)); + Mock mockPreInit2 = CreatePreScopeInitializer(nameof(mockPreInit2), Task.FromException(expectedError)); - ApplicationLifecycle sut = new( - MockScopeProvider.Object, - MockLifecycleViewController.Object, - [mockPreInit1.Object, mockPreInit2.Object], - MockLogger); + ApplicationLifecycle sut = CreateSut(mockPreInit1, mockPreInit2); // Act Task startTask = sut.StartAsync(default); startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); // Assert - execution should fail before creating scope - MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_UnhandledError(expectedError), TimeSpan.FromSeconds(1)) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_PreinitializerFailed(mockPreInit2.Object.Name, expectedError), TimeSpan.FromSeconds(1)) .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); MockLogger.Messages.Should().NotContain(m => m.Contains("WaitingForScope")); mockPreInit1.Verify(); @@ -819,20 +1375,17 @@ public void ApplicationLifecycle_StartAsync_LastPreInitializerFails_StopsBeforeS public void ApplicationLifecycle_StartAsync_PreInitializerCreatesActivityForEach() { // Arrange - Mock mockPreInit = new(); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); Mock mockActivity = new(); - mockPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - MockLifecycleViewController - .Setup(x => x.StartTask(It.Is(s => s.Contains("CloudAssetOrchestrator") || s.Contains("PreScopeInitializer")))) + .Setup(x => x.StartTask(Phrases.Startup_Preinitializing(mockPreInit.Object.Name))) .Returns(mockActivity.Object) .Verifiable(Times.Once); - ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + ApplicationLifecycle sut = CreateSut(mockPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -852,6 +1405,18 @@ public void ApplicationLifecycle_StartAsync_PreInitializerCreatesActivityForEach // Assert - Activity should be disposed after pre-initializer completes mockActivity.Verify(x => x.Dispose(), Times.Once, "Activity should be disposed after pre-initializer completes"); + + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); } [TestMethod] @@ -859,23 +1424,13 @@ public void ApplicationLifecycle_StartAsync_PreInitializerActivity_DisposesImmed { // Arrange TaskCompletionSource slowPreInitTcs = new(); - Mock fastPreInit = new(); - Mock slowPreInit = new(); + Mock fastPreInit = CreatePreScopeInitializer(nameof(fastPreInit)); + Mock slowPreInit = CreatePreScopeInitializer(nameof(slowPreInit), slowPreInitTcs.Task); Mock fastActivity = new(); Mock slowActivity = new(); int callCount = 0; - fastPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - - slowPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(slowPreInitTcs.Task) - .Verifiable(Times.Once); - // Mock StartTask to return different activities based on call order MockLifecycleViewController .Setup(x => x.StartTask(It.IsAny())) @@ -894,11 +1449,9 @@ public void ApplicationLifecycle_StartAsync_PreInitializerActivity_DisposesImmed return MockActivity.Object; }); - ApplicationLifecycle sut = new( - MockScopeProvider.Object, - MockLifecycleViewController.Object, - [fastPreInit.Object, slowPreInit.Object], - MockLogger); + ApplicationLifecycle sut = CreateSut(fastPreInit, slowPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -924,6 +1477,19 @@ public void ApplicationLifecycle_StartAsync_PreInitializerActivity_DisposesImmed // Assert - Now slow activity should be disposed too slowActivity.Verify(x => x.Dispose(), Times.Once, "Slow activity should be disposed after completing"); - } + // The first log message is for waiting for the slow preinitializer + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForPreinitializer("slowPreInit"); + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + } } diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs index da8e1165..ccace17b 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs +++ b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs @@ -42,15 +42,8 @@ exception is AssertInconclusiveException || OutputWriter?.WriteLine(message); } - public void VerifyMessages(Action expected) - { - MockLogger expectedLog = new(); - expectedLog.ReplaceStrings.AddRange(ReplaceStrings); - MessageLogger messageLogger = new(expectedLog); - expected(messageLogger); - - VerifyMessages(expectedLog._messages.ToArray()); - } + public void VerifyMessages(Action expected) + => VerifyMessages(GetMessageLogMessages(expected)); public void VerifyMessages(params string[] expected) { @@ -102,6 +95,16 @@ public void VerifyMessages(params string[] expected) } } + private string[] GetMessageLogMessages(Action expected) + { + MockLogger expectedLog = new(); + expectedLog.ReplaceStrings.AddRange(ReplaceStrings); + MessageLogger messageLogger = new(expectedLog); + expected(messageLogger); + + return expectedLog._messages.ToArray(); + } + private static List GetRemaining(IEnumerator iter, ref int count) { List remaining = new(); @@ -161,6 +164,19 @@ internal async Task WaitForMessageAsync(string expectedMessage, TimeSpan timeout } } + internal int CountMessages(Action messages) + => CountMessages(GetMessageLogMessages(messages)); + + public int CountMessages(params string[] messages) + { + int count = 0; + foreach (string expected in messages) + { + count += _messages.Count(m => m.StartsWith(expected)); + } + return count; + } + internal void ClearMessages() { _messages.Clear();