Skip to content

Commit b7ef9d9

Browse files
Enable Apps to call services when app shuts down in Dispose(Async)() and OnComplete of HA events (#1140)
* Enable Apps to call services when app shuts down in Dispose(Async)() and OnCOmplete of HA events * Fix warninng
1 parent 0315ea1 commit b7ef9d9

File tree

9 files changed

+206
-146
lines changed

9 files changed

+206
-146
lines changed

src/Client/NetDaemon.HassClient/Internal/HomeAssistantConnection.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,21 +164,21 @@ await Task.WhenAny(
164164

165165
public Task<T?> GetApiCallAsync<T>(string apiPath, CancellationToken cancelToken)
166166
{
167-
ObjectDisposedException.ThrowIf(_isDisposed, nameof(HomeAssistantConnection));
167+
ObjectDisposedException.ThrowIf(_isDisposed, this);
168168
return _apiManager.GetApiCallAsync<T>(apiPath, cancelToken);
169169
}
170170

171171
public Task<T?> PostApiCallAsync<T>(string apiPath, CancellationToken cancelToken, object? data = null)
172172
{
173-
ObjectDisposedException.ThrowIf(_isDisposed, nameof(HomeAssistantConnection));
173+
ObjectDisposedException.ThrowIf(_isDisposed, this);
174174
return _apiManager.PostApiCallAsync<T>(apiPath, cancelToken, data);
175175
}
176176

177177
public IObservable<HassMessage> OnHassMessage => _hassMessageSubject;
178178

179179
private async Task<Task<HassMessage>> SendCommandAsyncInternal<T>(T command, CancellationToken cancelToken) where T : CommandMessage
180180
{
181-
ObjectDisposedException.ThrowIf(_isDisposed, nameof(HomeAssistantConnection));
181+
ObjectDisposedException.ThrowIf(_isDisposed, this);
182182

183183
// The semaphore can fail to be taken in rare cases so we need
184184
// to keep this out of the try/finally block so it will not be released

src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,27 @@ public async Task EventsStopAfterDispose()
280280
eventObserverMock.VerifyNoOtherCalls();
281281
}
282282

283+
[Fact]
284+
public async Task CanCallServiceInEventCompleteDueToDispose()
285+
{
286+
// Arrange
287+
var provider = await CreateServiceProvider();
288+
var serviceScope = provider.CreateScope();
289+
var haContext = serviceScope.ServiceProvider.GetRequiredService<IHaContext>();
290+
291+
haContext.Events.Subscribe(onNext: _ => { }, onError: _ => { }, onCompleted:()=>{haContext.CallService("Light", "turn_on");});
292+
293+
// Disposing the entire Scope will Dispose the AppScopedHaContextProvider, which will then first Dispose its QueuedObservable.
294+
// That triggers haContext.Events.OnComplete().
295+
// We want to make sure that any code in event handlers for OnComplete is still able to call services
296+
await ((IAsyncDisposable)serviceScope).DisposeAsync();
297+
298+
// Assert
299+
_hassConnectionMock.Verify(
300+
c => c.SendCommandAsync(It.IsAny<CallServiceCommand>(),
301+
It.IsAny<CancellationToken>()), Times.Once);
302+
}
303+
283304
[Fact]
284305
public async Task TestThatCallServiceTrackBackgroundTask()
285306
{
@@ -368,6 +389,5 @@ public record TestEventData(string command, int endpoint_id, string otherField);
368389
public void Dispose()
369390
{
370391
_hassEventSubjectMock.Dispose();
371-
GC.SuppressFinalize(this);
372392
}
373393
}

src/HassModel/NetDaemon.HassModel.Tests/Internal/QueuedObservabeTest.cs

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
using System.Reactive.Subjects;
22
using Microsoft.Extensions.Logging;
3+
using Microsoft.Extensions.Logging.Abstractions;
34
using NetDaemon.HassModel.Internal;
45
using NetDaemon.HassModel.Tests.TestHelpers;
56

67
namespace NetDaemon.HassModel.Tests.Internal;
78

8-
public class QueuedObservabeTest
9+
public class QueuedObservableTest
910
{
1011
[Fact]
11-
public async Task EventsSouldbeforwarded()
12+
public async Task OnNextShouldBeForwarded()
1213
{
1314
var source = new Subject<int>();
1415

15-
var queue = new QueuedObservable<int>(Mock.Of<ILogger<IHaContext>>());
16-
queue.Initialize(source);
16+
var queue = new QueuedObservable<int>(source, NullLogger.Instance);
1717

1818
var subscriber = new Mock<IObserver<int>>();
1919
queue.Subscribe(subscriber.Object);
@@ -27,20 +27,48 @@ public async Task EventsSouldbeforwarded()
2727
subscriber.Verify(s => s.OnNext(3), Times.Once);
2828
}
2929

30+
[Fact]
31+
public async Task OnErrorShouldBeForwarded()
32+
{
33+
var source = new Subject<int>();
34+
var queue = new QueuedObservable<int>(source, NullLogger.Instance);
35+
var subscriber = new Mock<IObserver<int>>();
36+
queue.Subscribe(subscriber.Object);
37+
38+
var exception = new Exception("TestException");
39+
source.OnError(exception);
40+
41+
await queue.DisposeAsync();
42+
subscriber.Verify(s => s.OnError(exception));
43+
}
44+
45+
[Fact]
46+
public async Task? OnCompletedShouldBeForwarded()
47+
{
48+
var source = new Subject<int>();
49+
var queue = new QueuedObservable<int>(source, NullLogger.Instance);
50+
var subscriber = new Mock<IObserver<int>>();
51+
52+
queue.Subscribe(subscriber.Object);
53+
54+
source.OnCompleted();
55+
56+
await queue.DisposeAsync();
57+
subscriber.Verify(s => s.OnCompleted());
58+
}
59+
3060
[Fact]
3161
public async Task SubscribersShouldNotBlockEachOther()
3262
{
3363
var source = new Subject<int>();
3464

35-
var queue1 = new QueuedObservable<int>(Mock.Of<ILogger<IHaContext>>());
36-
queue1.Initialize(source);
65+
var queue1 = new QueuedObservable<int>(source, NullLogger.Instance);
3766

3867
var subscriber = new Mock<IObserver<int>>();
3968
queue1.Subscribe(subscriber.Object);
4069

4170
// The second subscriber will block the first event
42-
var queue2 = new QueuedObservable<int>(Mock.Of<ILogger<IHaContext>>());
43-
queue2.Initialize(source);
71+
var queue2 = new QueuedObservable<int>(source, NullLogger.Instance);
4472
var blockSubscribers = new ManualResetEvent(false);
4573
queue2.Subscribe(_ => blockSubscribers.WaitOne());
4674

@@ -62,12 +90,9 @@ public async Task SubscribersShouldNotBlockEachOther()
6290
public async Task WhenScopeIsDisposedSubscribersAreDetached()
6391
{
6492
var testSubject = new Subject<string>();
65-
var loggerMock = new Mock<ILogger<IHaContext>>();
6693
// Create 2 ScopedObservables for the same subject
67-
var scoped1 = new QueuedObservable<string>(loggerMock.Object);
68-
scoped1.Initialize(testSubject);
69-
var scoped2 = new QueuedObservable<string>(loggerMock.Object);
70-
scoped2.Initialize(testSubject);
94+
var scoped1 = new QueuedObservable<string>(testSubject, NullLogger.Instance);
95+
var scoped2 = new QueuedObservable<string>(testSubject, NullLogger.Instance);
7196

7297
// First scope has 2 subscribers, second has 1
7398
var scope1AObserverMock = new Mock<IObserver<string>>();
@@ -78,11 +103,11 @@ public async Task WhenScopeIsDisposedSubscribersAreDetached()
78103
var scope2ObserverMock = new Mock<IObserver<string>>();
79104
scoped2.Subscribe(scope2ObserverMock.Object);
80105

81-
var waitTasks = new Task[]
106+
var waitTasks = new[]
82107
{
83-
scope1AObserverMock.WaitForInvocationAndVerify(o => o.OnNext("Event1")),
84-
scope1BObserverMock.WaitForInvocationAndVerify(o => o.OnNext("Event1")),
85-
scope2ObserverMock.WaitForInvocationAndVerify(o => o.OnNext("Event1"))
108+
scope1AObserverMock.WaitForInvocationAndVerify(o => o.OnNext("Event1")),
109+
scope1BObserverMock.WaitForInvocationAndVerify(o => o.OnNext("Event1")),
110+
scope2ObserverMock.WaitForInvocationAndVerify(o => o.OnNext("Event1"))
86111
};
87112

88113
// Now start firing events
@@ -109,8 +134,7 @@ public async Task WhenScopeIsDisposedSubscribersAreDetached()
109134
public async Task TestQueuedObservableShouldHaveFinishedTasksOnDispose()
110135
{
111136
var source = new Subject<int>();
112-
var queue = new QueuedObservable<int>(Mock.Of<ILogger<IHaContext>>());
113-
queue.Initialize(source);
137+
var queue = new QueuedObservable<int>(source, NullLogger.Instance);
114138
var subscriber = new Mock<IObserver<int>>();
115139
queue.Subscribe(subscriber.Object);
116140

@@ -129,8 +153,7 @@ public async Task TestQueuedObservableShouldLogOnException()
129153
{
130154
var source = new Subject<int>();
131155
var loggerMock = new Mock<ILogger<IHaContext>>();
132-
var queue = new QueuedObservable<int>(loggerMock.Object);
133-
queue.Initialize(source);
156+
var queue = new QueuedObservable<int>(source,loggerMock.Object);
134157
var subscriber = new Mock<IObserver<int>>();
135158
subscriber.Setup(n => n.OnNext(1)).Throws<InvalidOperationException>();
136159
queue.Subscribe(subscriber.Object);
@@ -152,9 +175,7 @@ public async Task TestQueuedObservableShouldLogOnException()
152175
public async Task TestQueuedObservableShouldStillHaveSubscribersOnException()
153176
{
154177
var source = new Subject<int>();
155-
var loggerMock = new Mock<ILogger<IHaContext>>();
156-
var queue = new QueuedObservable<int>(loggerMock.Object);
157-
queue.Initialize(source);
178+
var queue = new QueuedObservable<int>(source, NullLogger.Instance);
158179
var subscriber = new Mock<IObserver<int>>();
159180
subscriber.Setup(n => n.OnNext(1)).Throws<InvalidOperationException>();
160181
queue.Subscribe(subscriber.Object);

src/HassModel/NetDeamon.HassModel/DependencyInjectionSetup.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Microsoft.Extensions.Hosting;
2-
using NetDaemon.Infrastructure.ObservableHelpers;
32

43
namespace NetDaemon.HassModel;
54

@@ -36,8 +35,6 @@ public static void AddScopedHaContext(this IServiceCollection services)
3635
services.AddScoped<IBackgroundTaskTracker>(s => s.GetRequiredService<BackgroundTaskTracker>());
3736
services.AddTransient<ICacheManager, CacheManager>();
3837
services.AddTransient<IHaContext>(s => s.GetRequiredService<AppScopedHaContextProvider>());
39-
services.AddScoped<QueuedObservable<HassEvent>>();
40-
services.AddScoped<IQueuedObservable<HassEvent>>(s => s.GetRequiredService<QueuedObservable<HassEvent>>());
4138
services.AddScoped<TriggerManager>();
4239
services.AddTransient<ITriggerManager>(s => s.GetRequiredService<TriggerManager>());
4340
}

src/HassModel/NetDeamon.HassModel/IQueuedObservable.cs

Lines changed: 0 additions & 16 deletions
This file was deleted.

src/HassModel/NetDeamon.HassModel/Internal/AppScopedHaContextProvider.cs

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using NetDaemon.Client.HomeAssistant.Extensions;
2-
using NetDaemon.Infrastructure.ObservableHelpers;
32

43
namespace NetDaemon.HassModel.Internal;
54

@@ -11,34 +10,34 @@ namespace NetDaemon.HassModel.Internal;
1110
internal class AppScopedHaContextProvider : IHaContext, IAsyncDisposable
1211
{
1312
private volatile bool _isDisposed;
13+
private volatile bool _isDisposing;
1414
private readonly IHomeAssistantApiManager _apiManager;
1515
private readonly EntityStateCache _entityStateCache;
1616

1717
private readonly IHomeAssistantRunner _hassRunner;
18-
private readonly IQueuedObservable<HassEvent> _queuedObservable;
18+
private readonly QueuedObservable<HassEvent> _queuedObservable;
1919
private readonly IBackgroundTaskTracker _backgroundTaskTracker;
20+
private readonly ILogger<AppScopedHaContextProvider> _logger;
2021

2122
private readonly CancellationTokenSource _tokenSource = new();
2223

2324
public AppScopedHaContextProvider(
2425
EntityStateCache entityStateCache,
2526
IHomeAssistantRunner hassRunner,
2627
IHomeAssistantApiManager apiManager,
27-
IQueuedObservable<HassEvent> queuedObservable,
2828
IBackgroundTaskTracker backgroundTaskTracker,
29-
IServiceProvider serviceProvider
30-
)
29+
IServiceProvider serviceProvider,
30+
ILogger<AppScopedHaContextProvider> logger)
3131
{
3232
_entityStateCache = entityStateCache;
3333
_hassRunner = hassRunner;
3434
_apiManager = apiManager;
3535

36-
// Create ScopedObservables for this app
36+
// Create QueuedObservable for this app
3737
// This makes sure we will unsubscribe when this ContextProvider is Disposed
38-
_queuedObservable = queuedObservable;
38+
_queuedObservable = new QueuedObservable<HassEvent>(_entityStateCache.AllEvents, logger);
3939
_backgroundTaskTracker = backgroundTaskTracker;
40-
41-
_queuedObservable.Initialize(_entityStateCache.AllEvents);
40+
_logger = logger;
4241

4342
// The HaRegistry needs a reference to this AppScopedHaContextProvider And we need the reference
4443
// to the AppScopedHaContextProvider here. Therefore we create it manually providing this
@@ -68,13 +67,15 @@ public IReadOnlyList<Entity> GetAllEntities()
6867

6968
public void CallService(string domain, string service, ServiceTarget? target = null, object? data = null)
7069
{
71-
ObjectDisposedException.ThrowIf(_isDisposed, nameof(AppScopedHaContextProvider));
72-
_backgroundTaskTracker.TrackBackgroundTask(_hassRunner.CurrentConnection?.CallServiceAsync(domain, service, data, target.Map(), _tokenSource.Token), "Error in sending event");
70+
ObjectDisposedException.ThrowIf(_isDisposed, this);
71+
_ = _hassRunner.CurrentConnection ?? throw new InvalidOperationException("No connection to Home Assistant");
72+
73+
_backgroundTaskTracker.TrackBackgroundTask(_hassRunner.CurrentConnection.CallServiceAsync(domain, service, data, target.Map(), _tokenSource.Token), "Error in sending event");
7374
}
7475

7576
public async Task<JsonElement?> CallServiceWithResponseAsync(string domain, string service, ServiceTarget? target = null, object? data = null)
7677
{
77-
ObjectDisposedException.ThrowIf(_isDisposed, nameof(AppScopedHaContextProvider));
78+
ObjectDisposedException.ThrowIf(_isDisposed, this);
7879
_ = _hassRunner.CurrentConnection ?? throw new InvalidOperationException("No connection to Home Assistant");
7980

8081
var result = await _hassRunner.CurrentConnection
@@ -94,22 +95,25 @@ public IObservable<StateChange> StateAllChanges()
9495
public IObservable<Event> Events => _queuedObservable
9596
.Select(e => e.Map());
9697

97-
9898
public void SendEvent(string eventType, object? data = null)
9999
{
100-
ObjectDisposedException.ThrowIf(_isDisposed, nameof(AppScopedHaContextProvider));
100+
ObjectDisposedException.ThrowIf(_isDisposed, this);
101101
_backgroundTaskTracker.TrackBackgroundTask(_apiManager.SendEventAsync(eventType, _tokenSource.Token, data), "Error in sending event");
102102
}
103103

104104
public async ValueTask DisposeAsync()
105105
{
106-
if (_isDisposed) return;
107-
_isDisposed = true;
106+
if (_isDisposing) return;
107+
_isDisposing = true;
108+
109+
// The order here is important, we want to allow apps to process their pending events and wait for background tasks to complete
110+
// before actually shutting down
111+
await _queuedObservable.DisposeAsync().ConfigureAwait(false);
112+
await _backgroundTaskTracker.DisposeAsync().ConfigureAwait(false);
108113

109-
if (!_tokenSource.IsCancellationRequested)
110-
await _tokenSource.CancelAsync();
111-
// Wait for all background tasks to complete before disposing the CancellationTokenSource
112-
await _backgroundTaskTracker.DisposeAsync();
114+
await _tokenSource.CancelAsync().ConfigureAwait(false);
115+
116+
_isDisposed = true;
113117
_tokenSource.Dispose();
114118
}
115119
}

0 commit comments

Comments
 (0)