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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<IHaContext>();
var backgroundTrackerMock = provider.GetRequiredService<Mock<IBackgroundTaskTracker>>();
var target = ServiceTarget.FromEntity("domain.entity");
var data = new { Name = "value" };

haContext.CallService("domain", "service", target, data);

backgroundTrackerMock.Verify(n => n.TrackBackgroundTask(It.IsAny<Task?>(), It.IsAny<string>()), Times.Once);
}

[Fact]
public async Task TestThatSendEventTrackBackgroundTask()
{
var provider = await CreateServiceProvider();

var haContext = provider.CreateScope().ServiceProvider.GetRequiredService<IHaContext>();
var backgroundTrackerMock = provider.GetRequiredService<Mock<IBackgroundTaskTracker>>();

haContext.SendEvent("any_type", null);

backgroundTrackerMock.Verify(n => n.TrackBackgroundTask(It.IsAny<Task?>(), It.IsAny<string>()), Times.Once);
}

private async Task<IHaContext> CreateTargetAsync()
{
var provider = await CreateServiceProvider();
Expand All @@ -180,6 +209,8 @@ private async Task<ServiceProvider> CreateServiceProvider()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddLogging();


serviceCollection.AddSingleton(_hassConnectionMock.Object);
serviceCollection.AddSingleton<IObservable<HassEvent>>(_hassEventSubjectMock);

Expand All @@ -193,6 +224,10 @@ private async Task<ServiceProvider> CreateServiceProvider()
serviceCollection.AddSingleton(_ => apiManagerMock.Object);
serviceCollection.AddScopedHaContext();

var backgroundTaskTrackerMock = new Mock<IBackgroundTaskTracker>();
serviceCollection.AddScoped<Mock<IBackgroundTaskTracker>>(_=> backgroundTaskTrackerMock);
serviceCollection.AddScoped(_ => backgroundTaskTrackerMock.Object);

var provider = serviceCollection.BuildServiceProvider();

await provider.GetRequiredService<ICacheManager>().InitializeAsync(CancellationToken.None);
Expand All @@ -201,4 +236,4 @@ private async Task<ServiceProvider> CreateServiceProvider()
}

public record TestEventData(string command, int endpoint_id, string otherField);
}
}
Original file line number Diff line number Diff line change
@@ -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<ILogger<BackgroundTaskTracker>> _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<EventId>(),
It.Is<It.IsAnyType>((_, __) => true),
It.IsAny<Exception>(),
It.Is<Func<It.IsAnyType, Exception, string>>((_, _) => true)!), Times.Once);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ internal static void AddScopedHaContext(this IServiceCollection services)
services.AddSingleton<EntityStateCache>();
services.AddSingleton<EntityAreaCache>();
services.AddScoped<AppScopedHaContextProvider>();
services.AddScoped<BackgroundTaskTracker>();
services.AddScoped<IBackgroundTaskTracker>(s => s.GetRequiredService<BackgroundTaskTracker>());
services.AddTransient<ICacheManager, CacheManager>();
services.AddTransient<IHaContext>(s => s.GetRequiredService<AppScopedHaContextProvider>());
services.AddScoped<QueuedObservable<HassEvent>>();
services.AddScoped<IQueuedObservable<HassEvent>>(s => s.GetRequiredService<QueuedObservable<HassEvent>>());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Threading;
using System.Collections.Concurrent;
using System.Threading;
using NetDaemon.Client.HomeAssistant.Extensions;
using NetDaemon.Infrastructure.ObservableHelpers;

Expand All @@ -17,6 +18,7 @@ internal class AppScopedHaContextProvider : IHaContext, IAsyncDisposable

private readonly IHomeAssistantRunner _hassRunner;
private readonly IQueuedObservable<HassEvent> _queuedObservable;
private readonly IBackgroundTaskTracker _backgroundTaskTracker;

private readonly CancellationTokenSource _tokenSource = new();

Expand All @@ -25,8 +27,9 @@ public AppScopedHaContextProvider(
EntityAreaCache entityAreaCache,
IHomeAssistantRunner hassRunner,
IHomeAssistantApiManager apiManager,
IQueuedObservable<HassEvent> queuedObservable
)
IQueuedObservable<HassEvent> queuedObservable,
IBackgroundTaskTracker backgroundTaskTracker
)
{
_entityStateCache = entityStateCache;
_entityAreaCache = entityAreaCache;
Expand All @@ -36,6 +39,7 @@ IQueuedObservable<HassEvent> 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);
}

Expand All @@ -56,7 +60,7 @@ public IReadOnlyList<Entity> 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<StateChange> StateAllChanges()
Expand All @@ -73,8 +77,7 @@ public IObservable<StateChange> 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()
Expand All @@ -85,4 +88,4 @@ public ValueTask DisposeAsync()

return ValueTask.CompletedTask;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Collections.Concurrent;

namespace NetDaemon.HassModel.Internal;

internal class BackgroundTaskTracker : IBackgroundTaskTracker
{
private readonly ILogger<BackgroundTaskTracker> _logger;
internal readonly ConcurrentDictionary<Task, object?> BackgroundTasks = new();

public BackgroundTaskTracker(ILogger<BackgroundTaskTracker> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace NetDaemon.HassModel.Internal;

internal interface IBackgroundTaskTracker : IAsyncDisposable
{
public void TrackBackgroundTask(Task? task, string? description = null);
}