Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 6 additions & 5 deletions src/AdaptiveRemote.App/Components/BlazorAppScope.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -47,12 +49,11 @@ public Task InvokeInScopeAsync(Func<IServiceProvider, CancellationToken, Task> 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<IJSRuntime>();
await jsRuntime.InvokeVoidAsync("location.reload");
Comment thread
jodavis marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -58,5 +59,6 @@ private static IServiceCollection AddApplicationLifecycleServices(this IServiceC
.AddScoped<ScopedLifecycleContainer>()
.AddScoped<Components.BlazorAppScope>()
.AddSingleton<IApplicationScopeContainer, ApplicationScopeContainer>()
.AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService<IApplicationScopeContainer>());
.AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService<IApplicationScopeContainer>())
.AddSingleton<IApplicationRecycleSignal, ApplicationRecycleSignal>();
}
14 changes: 12 additions & 2 deletions src/AdaptiveRemote.App/Logging/MessageLogger.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Net;
using System.Runtime.Intrinsics.Arm;
using Microsoft.Extensions.Logging;

namespace AdaptiveRemote.Logging;
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/AdaptiveRemote.App/Models/Phrases.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace AdaptiveRemote.Models;
using AdaptiveRemote.Services.Lifecycle;

namespace AdaptiveRemote.Models;

internal static class Phrases
{
Expand All @@ -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}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public CloudAssetOrchestrator(
_log = new MessageLogger(logger);
}

public string Name => "Loading cloud assets";

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
Expand Down
154 changes: 100 additions & 54 deletions src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,18 +10,21 @@ internal class ApplicationLifecycle : BackgroundService
{
private readonly IApplicationScopeProvider _scopeProvider;
private readonly ILifecycleViewController _viewController;
private readonly IApplicationRecycleSignal _signal;
private readonly IEnumerable<IPreScopeInitializer> _preInitializers;
private readonly MessageLogger _logger;
private ScopedLifecycleContainer? _currentContainer;

public ApplicationLifecycle(
IApplicationScopeProvider scopeProvider,
ILifecycleViewController viewController,
IApplicationRecycleSignal signal,
IEnumerable<IPreScopeInitializer> preInitializers,
ILogger<ApplicationLifecycle> logger)
{
_scopeProvider = scopeProvider;
_viewController = viewController;
_signal = signal;
_preInitializers = preInitializers;
_logger = new(logger);
}
Expand All @@ -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)
{
Expand All @@ -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<bool> 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<bool> 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<ScopedLifecycleContainer>();
}
catch (Exception ex)
{
_logger.ApplicationLifecycle_ScopeConstructionFailed(ex);
_viewController.SetFatalError(ex);
return null;
}
return provider.GetRequiredService<ScopedLifecycleContainer>();
}
catch (Exception ex)
{
_logger.ApplicationLifecycle_ScopeConstructionFailed(ex);
_viewController.SetFatalError(ex);
return null;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading