Skip to content

Commit 4a3cbb5

Browse files
authored
Stability fixes (#1131)
* Make the disposing of application context thread safe * Fix the example app using get calendar events * Make the handling of cancellation more roboust to avoid the source to be disposed before all tasks been completed * added tests * Fixed the throw instead * Ignore and tracelog the operation cancelled exception in background task * revert throwifcanceled #1 * revert throwif cancelled #2 * Add the Flush feature on BackgroundTaskTracker * Fix the scheduler to be more robust and not call actions on disposed schedulers * Fix the code comment using interlocked instead * Fix warning and missing setting the disposed flag * add test * Added context tests * Added even more tests * add tests * fix test * remove the last of throwifcancelled * Add one more test * renaming * Fix comments and from discussion * removed timing using TaskSource * using interlocked on more places * removed the disposable timer * fix last interlocked case * forgott the test * revert back to volatile bools * Added more checks for disposed and throws if so * added test for multople dispose * fix from comment
1 parent 089f848 commit 4a3cbb5

File tree

21 files changed

+453
-121
lines changed

21 files changed

+453
-121
lines changed

src/AppModel/NetDaemon.AppModel.Tests/AppModelTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,21 @@ public async Task TestGetApplicationsLocalWithDisposable()
233233
app!.DisposeIsCalled.Should().BeTrue();
234234
}
235235

236+
[Fact]
237+
public async Task TestApplicationCanDisposeMultipleTimesWithoutExceptions()
238+
{
239+
// ARRANGE
240+
// ACT
241+
var loadApps = await TestHelpers.GetLocalApplicationsFromYamlConfigPath("Fixtures/Local");
242+
243+
// CHECK
244+
245+
// check the application instance is init ok
246+
var application = loadApps.First(n => n.Id == "LocalApps.MyAppLocalAppWithDispose");
247+
await application.DisposeAsync().ConfigureAwait(false);
248+
await application.DisposeAsync().ConfigureAwait(false);
249+
}
250+
236251
[Fact]
237252
public async Task TestGetApplicationsLocalWithAsyncDisposable()
238253
{
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using NetDaemon.AppModel.Internal;
2+
using NetDaemon.AppModel.Internal.AppFactories;
3+
4+
namespace NetDaemon.AppModel.Tests.Context;
5+
6+
public class ApplicationContextTests
7+
{
8+
[Fact]
9+
public async Task TestApplicationContextIsDisposedMultipleTimesNotThrowsException()
10+
{
11+
var serviceProvider = new ServiceCollection().BuildServiceProvider();
12+
var appFactory = Mock.Of<IAppFactory>();
13+
var applicationContext = new ApplicationContext(serviceProvider, appFactory);
14+
15+
await applicationContext.DisposeAsync();
16+
await applicationContext.DisposeAsync();
17+
}
18+
}

src/AppModel/NetDaemon.AppModel/Internal/AppModelContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ internal class AppModelContext : IAppModelContext
1010
private readonly IServiceProvider _provider;
1111
private readonly FocusFilter _focusFilter;
1212
private ILogger<AppModelContext> _logger;
13-
private bool _isDisposed;
13+
private volatile bool _isDisposed;
1414

1515
public AppModelContext(IEnumerable<IAppFactoryProvider> appFactoryProviders, IServiceProvider provider, FocusFilter focusFilter, ILogger<AppModelContext> logger)
1616
{

src/AppModel/NetDaemon.AppModel/Internal/Context/ApplicationContext.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ internal sealed class ApplicationContext : IAsyncDisposable
66
{
77
private readonly CancellationTokenSource _cancelTokenSource = new();
88
private readonly IServiceScope? _serviceScope;
9-
private bool _isDisposed;
9+
private volatile bool _isDisposed;
1010

1111
public ApplicationContext(IServiceProvider serviceProvider, IAppFactory appFactory)
1212
{
@@ -34,9 +34,7 @@ public async Task InitializeAsync()
3434

3535
public async ValueTask DisposeAsync()
3636
{
37-
// prevent multiple Disposes because the Service Scope will also dispose this
3837
if (_isDisposed) return;
39-
4038
_isDisposed = true;
4139

4240
if (!_cancelTokenSource.IsCancellationRequested)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
namespace NetDaemon.HassClient.Tests.HomeAssistantClientTest;
2+
3+
public record FakeCommand : CommandMessage {}
4+
5+
public class HomeAssistantConnectionTests
6+
{
7+
private static IHomeAssistantConnection GetDefaultHomeAssistantConnection()
8+
{
9+
var pipeline = new TransportPipelineMock();
10+
pipeline.Setup(n => n.WebSocketState).Returns(WebSocketState.Open);
11+
var apiManagerMock = new Mock<IHomeAssistantApiManager>();
12+
var loggerMock = new Mock<ILogger<IHomeAssistantConnection>>();
13+
return new HomeAssistantConnection(loggerMock.Object, pipeline.Object, apiManagerMock.Object);
14+
}
15+
16+
[Fact]
17+
public async Task UsingDisposedConnectionWhenSendCommandShouldThrowException()
18+
{
19+
var homeAssistantConnection = GetDefaultHomeAssistantConnection();
20+
await homeAssistantConnection!.DisposeAsync();
21+
22+
Func<Task> act = async () =>
23+
{
24+
await homeAssistantConnection!.SendCommandAsync(new FakeCommand(), CancellationToken.None);
25+
};
26+
27+
await act.Should().ThrowAsync<ObjectDisposedException>();
28+
}
29+
30+
[Fact]
31+
public async Task UsingDisposedConnectionWhenGetApiCommandShouldThrowException()
32+
{
33+
var homeAssistantConnection = GetDefaultHomeAssistantConnection();
34+
await homeAssistantConnection!.DisposeAsync();
35+
36+
Func<Task> act = async () =>
37+
{
38+
await homeAssistantConnection!.GetApiCallAsync<string>("test", CancellationToken.None);
39+
};
40+
41+
await act.Should().ThrowAsync<ObjectDisposedException>();
42+
}
43+
44+
[Fact]
45+
public async Task UsingDisposedConnectionWhenPostApiShouldThrowException()
46+
{
47+
var homeAssistantConnection = GetDefaultHomeAssistantConnection();
48+
await homeAssistantConnection!.DisposeAsync();
49+
50+
Func<Task> act = async () =>
51+
{
52+
await homeAssistantConnection!.PostApiCallAsync<string>("test", CancellationToken.None, null);
53+
};
54+
55+
await act.Should().ThrowAsync<ObjectDisposedException>();
56+
}
57+
58+
[Fact]
59+
public async Task HomeAssistantConnectionDisposedMultipleTimesShouldNotThrow()
60+
{
61+
var homeAssistantConnection = GetDefaultHomeAssistantConnection();
62+
await homeAssistantConnection!.DisposeAsync();
63+
await homeAssistantConnection!.DisposeAsync();
64+
}
65+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ internal class HomeAssistantConnection : IHomeAssistantConnection, IHomeAssistan
44
{
55
#region -- private declarations -
66

7+
private volatile bool _isDisposed;
8+
79
private readonly ILogger<IHomeAssistantConnection> _logger;
810
private readonly IWebSocketClientTransportPipeline _transportPipeline;
911
private readonly IHomeAssistantApiManager _apiManager;
@@ -132,6 +134,9 @@ public async Task SendCommandAsync<T>(T command, CancellationToken cancelToken)
132134

133135
public async ValueTask DisposeAsync()
134136
{
137+
if (_isDisposed) return;
138+
_isDisposed = true;
139+
135140
try
136141
{
137142
await CloseAsync().ConfigureAwait(false);
@@ -159,18 +164,22 @@ await Task.WhenAny(
159164

160165
public Task<T?> GetApiCallAsync<T>(string apiPath, CancellationToken cancelToken)
161166
{
167+
ObjectDisposedException.ThrowIf(_isDisposed, nameof(HomeAssistantConnection));
162168
return _apiManager.GetApiCallAsync<T>(apiPath, cancelToken);
163169
}
164170

165171
public Task<T?> PostApiCallAsync<T>(string apiPath, CancellationToken cancelToken, object? data = null)
166172
{
173+
ObjectDisposedException.ThrowIf(_isDisposed, nameof(HomeAssistantConnection));
167174
return _apiManager.PostApiCallAsync<T>(apiPath, cancelToken, data);
168175
}
169176

170177
public IObservable<HassMessage> OnHassMessage => _hassMessageSubject;
171178

172179
private async Task<Task<HassMessage>> SendCommandAsyncInternal<T>(T command, CancellationToken cancelToken) where T : CommandMessage
173180
{
181+
ObjectDisposedException.ThrowIf(_isDisposed, nameof(HomeAssistantConnection));
182+
174183
// The semaphore can fail to be taken in rare cases so we need
175184
// to keep this out of the try/finally block so it will not be released
176185
await _messageIdSemaphore.WaitAsync(cancelToken).ConfigureAwait(false);

src/Extensions/NetDaemon.Extensions.MqttEntityManager/MessageSubscriber.cs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ namespace NetDaemon.Extensions.MqttEntityManager;
2121
internal class MessageSubscriber : IMessageSubscriber, IDisposable
2222
{
2323
private readonly SemaphoreSlim _subscriptionSetupLock = new SemaphoreSlim(1);
24-
private bool _isDisposed;
24+
private volatile bool _isDisposed;
2525
private bool _subscriptionIsSetup;
2626
private readonly IAssuredMqttConnection _assuredMqttConnection;
2727
private readonly ILogger<MessageSubscriber> _logger;
@@ -121,17 +121,16 @@ private Task OnMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs msg)
121121

122122
public void Dispose()
123123
{
124-
if (!_isDisposed)
125-
{
126-
_isDisposed = true;
127-
foreach (var observer in _subscribers)
128-
{
129-
_logger.LogTrace("Disposing {Topic} subscription", observer.Key);
130-
observer.Value.Value.OnCompleted();
131-
observer.Value.Value.Dispose();
132-
}
124+
if (_isDisposed) return;
125+
_isDisposed = true;
133126

134-
_subscriptionSetupLock.Dispose();
127+
foreach (var observer in _subscribers)
128+
{
129+
_logger.LogTrace("Disposing {Topic} subscription", observer.Key);
130+
observer.Value.Value.OnCompleted();
131+
observer.Value.Value.Dispose();
135132
}
133+
134+
_subscriptionSetupLock.Dispose();
136135
}
137-
}
136+
}

src/Extensions/NetDaemon.Extensions.Scheduling.Tests/Scheduling/DisposableSchedulerTest.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,47 @@ public void _SchedulePeriodicStopsAfterDisposeOfDisposableScheduler()
3535
}
3636

3737

38+
[Fact]
39+
public void PeriodicSchedulerShouldNotCallActionIfItIsDisposedDuringSchedule()
40+
{
41+
var (inner, disposableScheduler) = CreateScheduler();
42+
43+
int called = 0;
44+
using var _ = disposableScheduler.SchedulePeriodic(TimeSpan.FromMinutes(1), () => called++);
45+
46+
// Dispose before the time moves forward and trigger a schedule
47+
disposableScheduler.Dispose();
48+
49+
inner.AdvanceBy(TimeSpan.FromMinutes(1).Ticks);
50+
called.Should().Be(0);
51+
}
52+
53+
[Fact]
54+
public void PeriodicSchedulerShouldNotCallActionIfItIsDisposedBeforeScheduled()
55+
{
56+
var (inner, disposableScheduler) = CreateScheduler();
57+
58+
int called = 0;
59+
// Dispose before the we call schedule
60+
disposableScheduler.Dispose();
61+
using var _ = disposableScheduler.SchedulePeriodic(TimeSpan.FromMinutes(1), () => called++);
62+
inner.AdvanceBy(TimeSpan.FromMinutes(1).Ticks);
63+
called.Should().Be(0);
64+
}
65+
66+
[Fact]
67+
public void SchedulerShouldNotCallActionIfItIsDisposedBeforeScheduled()
68+
{
69+
var (inner, disposableScheduler) = CreateScheduler();
70+
71+
int called = 0;
72+
// Dispose before the we call schedule
73+
disposableScheduler.Dispose();
74+
using var _ = disposableScheduler.Schedule(() => called++);
75+
inner.AdvanceBy(TimeSpan.FromMinutes(1).Ticks);
76+
called.Should().Be(0);
77+
}
78+
3879
[Fact]
3980
public void SchedulePeriodicStopsAfterDisposeOfDisposableScheduler()
4081
{

src/Extensions/NetDaemon.Extensions.Scheduling.Tests/Scheduling/DisposableTimerTest.cs

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

0 commit comments

Comments
 (0)