diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs index 4c8454060..42b0200e0 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs @@ -11,6 +11,7 @@ using NetDaemon.Client.HomeAssistant.Model; using NetDaemon.Client.Internal.HomeAssistant.Commands; using NetDaemon.HassModel.Entities; +using NetDaemon.HassModel.Internal; using NetDaemon.HassModel.Tests.TestHelpers; using Xunit; @@ -168,6 +169,34 @@ public async Task EventsStopAfterDispose() eventObserverMock.VerifyNoOtherCalls(); } + [Fact] + public async Task TestThatCallServiceTrackBackgroundTask() + { + var provider = await CreateServiceProvider(); + + var haContext = provider.CreateScope().ServiceProvider.GetRequiredService(); + var backgroundTrackerMock = provider.GetRequiredService>(); + var target = ServiceTarget.FromEntity("domain.entity"); + var data = new { Name = "value" }; + + haContext.CallService("domain", "service", target, data); + + backgroundTrackerMock.Verify(n => n.TrackBackgroundTask(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task TestThatSendEventTrackBackgroundTask() + { + var provider = await CreateServiceProvider(); + + var haContext = provider.CreateScope().ServiceProvider.GetRequiredService(); + var backgroundTrackerMock = provider.GetRequiredService>(); + + haContext.SendEvent("any_type", null); + + backgroundTrackerMock.Verify(n => n.TrackBackgroundTask(It.IsAny(), It.IsAny()), Times.Once); + } + private async Task CreateTargetAsync() { var provider = await CreateServiceProvider(); @@ -180,6 +209,8 @@ private async Task CreateServiceProvider() { var serviceCollection = new ServiceCollection(); serviceCollection.AddLogging(); + + serviceCollection.AddSingleton(_hassConnectionMock.Object); serviceCollection.AddSingleton>(_hassEventSubjectMock); @@ -193,6 +224,10 @@ private async Task CreateServiceProvider() serviceCollection.AddSingleton(_ => apiManagerMock.Object); serviceCollection.AddScopedHaContext(); + var backgroundTaskTrackerMock = new Mock(); + serviceCollection.AddScoped>(_=> backgroundTaskTrackerMock); + serviceCollection.AddScoped(_ => backgroundTaskTrackerMock.Object); + var provider = serviceCollection.BuildServiceProvider(); await provider.GetRequiredService().InitializeAsync(CancellationToken.None); @@ -201,4 +236,4 @@ private async Task CreateServiceProvider() } public record TestEventData(string command, int endpoint_id, string otherField); -} \ No newline at end of file +} diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Internal/BackgroundTaskTrackerTests.cs b/src/HassModel/NetDaemon.HassModel.Tests/Internal/BackgroundTaskTrackerTests.cs new file mode 100644 index 000000000..d89afae11 --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.Tests/Internal/BackgroundTaskTrackerTests.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using NetDaemon.HassModel.Internal; +using Xunit; + +namespace NetDaemon.HassModel.Tests.Internal; + +public class BackgroundTaskTrackerTests +{ + private readonly BackgroundTaskTracker _backgroundTaskTracker; + private readonly Mock> _loggerMock = new(); + + public BackgroundTaskTrackerTests() + { + _backgroundTaskTracker = new BackgroundTaskTracker(_loggerMock.Object); + } + + [Fact] + public void TestBackgroundTaskNormalNotLogError() + { + bool isCalled; + + Task CallMe() + { + isCalled = true; + return Task.CompletedTask; + } + + var timedCancellationTokenSource = new CancellationTokenSource(5000); + + _backgroundTaskTracker.TrackBackgroundTask(CallMe()); + + var task = _backgroundTaskTracker.BackgroundTasks.FirstOrDefault(); + + if (task.Key is not null) + // We still have a task in queue so wait for it max 5000 ms + task.Key.Wait(timedCancellationTokenSource.Token); + + isCalled.Should().BeTrue(); + } + + [Fact] + public void TestBackgroundTaskThrowsExceptionWillLogError() + { +#pragma warning disable CS1998 + async Task CallMeAndIThrowError() +#pragma warning restore CS1998 + { + throw new InvalidOperationException("Test exception"); + } + + var timedCancellationTokenSource = new CancellationTokenSource(5000); + + _backgroundTaskTracker.TrackBackgroundTask(CallMeAndIThrowError()); + + var task = _backgroundTaskTracker.BackgroundTasks.FirstOrDefault(); + + if (task.Key is not null) + // We still have a task in queue so wait for it max 5000 ms + task.Key.Wait(timedCancellationTokenSource.Token); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((_, __) => true), + It.IsAny(), + It.Is>((_, _) => true)!), Times.Once); + } +} diff --git a/src/HassModel/NetDeamon.HassModel/DependencyInjectionSetup.cs b/src/HassModel/NetDeamon.HassModel/DependencyInjectionSetup.cs index c0f2880ff..87b4750d2 100644 --- a/src/HassModel/NetDeamon.HassModel/DependencyInjectionSetup.cs +++ b/src/HassModel/NetDeamon.HassModel/DependencyInjectionSetup.cs @@ -24,9 +24,11 @@ internal static void AddScopedHaContext(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(s => s.GetRequiredService()); services.AddTransient(); services.AddTransient(s => s.GetRequiredService()); services.AddScoped>(); services.AddScoped>(s => s.GetRequiredService>()); } -} \ No newline at end of file +} diff --git a/src/HassModel/NetDeamon.HassModel/Internal/AppScopedHaContextProvider.cs b/src/HassModel/NetDeamon.HassModel/Internal/AppScopedHaContextProvider.cs index 3869226b0..0632bb8b9 100644 --- a/src/HassModel/NetDeamon.HassModel/Internal/AppScopedHaContextProvider.cs +++ b/src/HassModel/NetDeamon.HassModel/Internal/AppScopedHaContextProvider.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Collections.Concurrent; +using System.Threading; using NetDaemon.Client.HomeAssistant.Extensions; using NetDaemon.Infrastructure.ObservableHelpers; @@ -17,6 +18,7 @@ internal class AppScopedHaContextProvider : IHaContext, IAsyncDisposable private readonly IHomeAssistantRunner _hassRunner; private readonly IQueuedObservable _queuedObservable; + private readonly IBackgroundTaskTracker _backgroundTaskTracker; private readonly CancellationTokenSource _tokenSource = new(); @@ -25,8 +27,9 @@ public AppScopedHaContextProvider( EntityAreaCache entityAreaCache, IHomeAssistantRunner hassRunner, IHomeAssistantApiManager apiManager, - IQueuedObservable queuedObservable - ) + IQueuedObservable queuedObservable, + IBackgroundTaskTracker backgroundTaskTracker + ) { _entityStateCache = entityStateCache; _entityAreaCache = entityAreaCache; @@ -36,6 +39,7 @@ IQueuedObservable queuedObservable // Create ScopedObservables for this app // This makes sure we will unsubscribe when this ContextProvider is Disposed _queuedObservable = queuedObservable; + _backgroundTaskTracker = backgroundTaskTracker; _queuedObservable.Initialize(_entityStateCache.AllEvents); } @@ -56,7 +60,7 @@ public IReadOnlyList GetAllEntities() public void CallService(string domain, string service, ServiceTarget? target = null, object? data = null) { - _hassRunner.CurrentConnection?.CallServiceAsync(domain, service, data, target.Map(), _tokenSource.Token); + _backgroundTaskTracker.TrackBackgroundTask(_hassRunner.CurrentConnection?.CallServiceAsync(domain, service, data, target.Map(), _tokenSource.Token), "Error in sending event"); } public IObservable StateAllChanges() @@ -73,8 +77,7 @@ public IObservable StateAllChanges() public void SendEvent(string eventType, object? data = null) { - // For now we do just a fire and forget of the async SendEvent method. HassClient will handle and log exceptions - _apiManager.SendEventAsync(eventType, _tokenSource.Token, data); + _backgroundTaskTracker.TrackBackgroundTask(_apiManager.SendEventAsync(eventType, _tokenSource.Token, data), "Error in sending event"); } public ValueTask DisposeAsync() @@ -85,4 +88,4 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/HassModel/NetDeamon.HassModel/Internal/BackgroundTaskTracker.cs b/src/HassModel/NetDeamon.HassModel/Internal/BackgroundTaskTracker.cs new file mode 100644 index 000000000..582d20144 --- /dev/null +++ b/src/HassModel/NetDeamon.HassModel/Internal/BackgroundTaskTracker.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; + +namespace NetDaemon.HassModel.Internal; + +internal class BackgroundTaskTracker : IBackgroundTaskTracker +{ + private readonly ILogger _logger; + internal readonly ConcurrentDictionary BackgroundTasks = new(); + + public BackgroundTaskTracker(ILogger logger) + { + _logger = logger; + } + + public void TrackBackgroundTask(Task? task, string? description = null) + { + ArgumentNullException.ThrowIfNull(task, nameof(task)); + + BackgroundTasks.TryAdd(task, null); + + [SuppressMessage("", "CA1031")] + async Task Wrap() + { + try + { + await task.ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogError(e, "Exception processing Home Assistant event: {Description}", description ?? ""); + } + finally + { + BackgroundTasks.TryRemove(task, out var _); + } + } + + // We do not handle task here cause exceptions + // are handled in the Wrap local functions and + // all tasks should be cancelable + _ = Wrap(); + } + + public async ValueTask DisposeAsync() + { + // Wait for the tasks to complete max 5 seconds + if (!BackgroundTasks.IsEmpty) + { + await Task.WhenAny( Task.WhenAll(BackgroundTasks.Keys), Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false); + } + } +} diff --git a/src/HassModel/NetDeamon.HassModel/Internal/IBackgroundTaskTracker.cs b/src/HassModel/NetDeamon.HassModel/Internal/IBackgroundTaskTracker.cs new file mode 100644 index 000000000..43f66c1be --- /dev/null +++ b/src/HassModel/NetDeamon.HassModel/Internal/IBackgroundTaskTracker.cs @@ -0,0 +1,6 @@ +namespace NetDaemon.HassModel.Internal; + +internal interface IBackgroundTaskTracker : IAsyncDisposable +{ + public void TrackBackgroundTask(Task? task, string? description = null); +}