Skip to content

Feature/prevent blocking start async #1303

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
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
13 changes: 6 additions & 7 deletions src/Client/NetDaemon.HassClient/Common/IHomeAssistantRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,22 @@ namespace NetDaemon.Client;
public interface IHomeAssistantRunner : IAsyncDisposable
{
/// <summary>
/// Event when new connection is established
/// Observable that emits when a (new) connection is established.
/// </summary>
IObservable<IHomeAssistantConnection> OnConnect { get; }

/// <summary>
/// Event when connection is lost
/// Observable that emits when the current connection is lost.
/// </summary>
IObservable<DisconnectReason> OnDisconnect { get; }

/// <summary>
/// The current connection to Home Assistant
/// The current connection to Home Assistant. Null if disconnected.
/// </summary>
/// <value></value>
IHomeAssistantConnection? CurrentConnection { get; }

/// <summary>
/// Maintains a connection to the Home Assistant server
/// Maintains a connection to the Home Assistant server
/// </summary>
/// <param name="host">Host of Home Assistant instance</param>
/// <param name="port">Port of Home Assistant instance</param>
Expand All @@ -30,7 +29,7 @@ public interface IHomeAssistantRunner : IAsyncDisposable
Task RunAsync(string host, int port, bool ssl, string token, TimeSpan timeout, CancellationToken cancelToken);

/// <summary>
/// Maintains a connection to the Home Assistant server
/// Maintains a connection to the Home Assistant server
/// </summary>
/// <param name="host">Host of Home Assistant instance</param>
/// <param name="port">Port of Home Assistant instance</param>
Expand All @@ -40,4 +39,4 @@ public interface IHomeAssistantRunner : IAsyncDisposable
/// <param name="timeout">Wait time between connects</param>
/// <param name="cancelToken">Cancel token</param>
Task RunAsync(string host, int port, bool ssl, string token, string websocketPath, TimeSpan timeout, CancellationToken cancelToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal class HomeAssistantRunner(IHomeAssistantClient client,

private readonly Subject<DisconnectReason> _onDisconnectSubject = new();

private readonly TimeSpan MaxTimeoutInSeconds = TimeSpan.FromSeconds(80);
private readonly TimeSpan _maxTimeoutInSeconds = TimeSpan.FromSeconds(80);

private Task? _runTask;

Expand Down Expand Up @@ -62,7 +62,7 @@ await Task.WhenAny(
private async Task InternalRunAsync(string host, int port, bool ssl, string token, string websocketPath, TimeSpan timeout,
CancellationToken cancelToken)
{
var progressiveTimeout = new ProgressiveTimeout(timeout, MaxTimeoutInSeconds, 2.0);
var progressiveTimeout = new ProgressiveTimeout(timeout, _maxTimeoutInSeconds, 2.0);
var combinedToken = CancellationTokenSource.CreateLinkedTokenSource(_internalTokenSource.Token, cancelToken);
while (!combinedToken.IsCancellationRequested)
{
Expand Down
2 changes: 1 addition & 1 deletion src/HassModel/NetDaemon.HassModel/ICacheManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ public interface ICacheManager
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task InitializeAsync(CancellationToken cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public async Task TestApplicationIsLoaded()
var runnerTask = host.RunAsync(timedCancellationSource.Token);

haRunner.MockConnect();
var service = (NetDaemonRuntime) host.Services.GetService<IRuntime>()!;
var service = (NetDaemonRuntime) host.Services.GetService<INetDaemonRuntime>()!;
var instances = service.ApplicationInstances;

instances.Where(n => n.Id == "LocalApps.LocalApp").Should().NotBeEmpty();
Expand All @@ -50,7 +50,7 @@ public async Task TestApplicationReactToNewEvents()

var runnerTask = host.StartAsync(timedCancellationSource.Token);
haRunner.MockConnect();
_ = (NetDaemonRuntime) host.Services.GetService<IRuntime>()!;
_ = (NetDaemonRuntime) host.Services.GetService<INetDaemonRuntime>()!;
await runnerTask.ConfigureAwait(false);

haRunner.ClientMock.ConnectionMock.AddStateChangeEvent(
Expand Down Expand Up @@ -89,7 +89,7 @@ public async Task TestApplicationReactToNewEventsAndThrowException()

var runnerTask = host.StartAsync(timedCancellationSource.Token);
haRunner.MockConnect();
_ = (NetDaemonRuntime) host.Services.GetService<IRuntime>()!;
_ = (NetDaemonRuntime) host.Services.GetService<INetDaemonRuntime>()!;

haRunner.ClientMock.ConnectionMock.AddStateChangeEvent(
new HassState
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,22 @@ public sealed class NetDaemonRuntimeTests : IDisposable


[Fact]
public async Task TestStartAsyncAsync()
public async Task TestEnsureInitializedAsyncSubscribesToHomeAssistantRunnerEvents()
{
await using var runtime = SetupNetDaemonRuntime();
var startingTask = runtime.StartAsync(CancellationToken.None);
_ = runtime.EnsureInitializedAsync();

_connectSubject.HasObservers.Should().BeTrue();
_disconnectSubject.HasObservers.Should().BeTrue();

startingTask.IsCompleted.Should().BeFalse();
}

[Fact]
public async Task TestOnConnect()
{
await using var runtime = SetupNetDaemonRuntime();
var startingTask = runtime.StartAsync(CancellationToken.None);
_ = runtime.EnsureInitializedAsync();

startingTask.IsCompleted.Should().BeFalse();
_connectSubject.OnNext(_homeAssistantConnectionMock.Object);
await startingTask.ConfigureAwait(false);

_appModelMock.Verify(n => n.LoadNewApplicationContext(It.IsAny<CancellationToken>()));

Expand All @@ -50,12 +46,11 @@ public async Task TestOnConnect()
public async Task TestOnDisconnect()
{
await using var runtime = SetupNetDaemonRuntime();
var startingTask = runtime.StartAsync(CancellationToken.None);
_ = runtime.EnsureInitializedAsync();

// First make sure we add an connection
_connectSubject.OnNext(_homeAssistantConnectionMock.Object);

await startingTask.ConfigureAwait(false);
runtime.IsConnected.Should().BeTrue();

// Then fake a disconnect
Expand All @@ -70,12 +65,11 @@ public async Task TestOnDisconnect()
public async Task TestReconnect()
{
await using var runtime = SetupNetDaemonRuntime();
var startingTask = runtime.StartAsync(CancellationToken.None);
_ = runtime.EnsureInitializedAsync();

// First make sure we add an connection
_connectSubject.OnNext(_homeAssistantConnectionMock.Object);

await startingTask.ConfigureAwait(false);
runtime.IsConnected.Should().BeTrue();

// Then fake a disconnect
Expand All @@ -101,10 +95,15 @@ public async Task TestOnConnectError()

await using var runtime = SetupNetDaemonRuntime();

var startAsync = runtime.StartAsync(CancellationToken.None);
_ = runtime.EnsureInitializedAsync();
_connectSubject.OnNext(_homeAssistantConnectionMock.Object);
Func<Task> startingTask = ()=> startAsync;
await startingTask.Should().ThrowAsync<InvalidOperationException>();

_loggerMock.Verify(
x => x.Log(LogLevel.Critical,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.Is<Func<It.IsAnyType, Exception, string>>((v, t) => true)!), times: Times.Once);
}


Expand All @@ -113,15 +112,14 @@ public async Task TestOnReConnectError()
{
await using var runtime = SetupNetDaemonRuntime();

_ = runtime.StartAsync(CancellationToken.None);
_ = runtime.EnsureInitializedAsync();
_connectSubject.OnNext(_homeAssistantConnectionMock.Object);
_disconnectSubject.OnNext(DisconnectReason.Client);

// now it should err on the second connection
_cacheManagerMock.Setup(m => m.InitializeAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("Something wrong while initializing"));
_connectSubject.OnNext(_homeAssistantConnectionMock.Object);


_loggerMock.Verify(
x => x.Log(LogLevel.Critical,
It.IsAny<EventId>(),
Expand Down
17 changes: 17 additions & 0 deletions src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace NetDaemon.Runtime;

/// <summary>
/// Enum to determine the auto reconnect behavior of the runtime.
/// </summary>
public enum AutoReconnectOptions
{
/// <summary>
/// Will stop automatically reconnecting if the Home Assistant server returns an Unauthorized response. This prevents the user from being locked out of the Home Assistant server. This is the default behavior.
/// </summary>
StopReconnectOnUnAuthorized,

/// <summary>
/// Will always attempt to reconnect to the Home Assistant server.
/// </summary>
AlwaysAttemptReconnect
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public static IHostBuilder UseNetDaemonRuntime(this IHostBuilder hostBuilder)
services.AddHostedService<RuntimeService>();
services.AddHomeAssistantClient();
services.Configure<HomeAssistantSettings>(context.Configuration.GetSection("HomeAssistant"));
services.AddSingleton<IRuntime, NetDaemonRuntime>();
services.AddSingleton<INetDaemonRuntime, NetDaemonRuntime>();
});
}
}
}
20 changes: 20 additions & 0 deletions src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace NetDaemon.Runtime;

/// <summary>
/// The NetDaemon runtime interface.
/// </summary>
public interface INetDaemonRuntime : IAsyncDisposable
{
/// <summary>
/// Starts the runtime and passes <paramref name="stoppingToken"/> to the runtime task/thread.
/// Will return a task that completes when the NetDaemon runtime is initialized.
///
/// Calling this method multiple times will only start the runtime once, but can be useful for other processes if they want to await the NetDaemon runtime to be initialized.
/// </summary>
Task EnsureInitializedAsync(CancellationToken stoppingToken = default);

/// <summary>
/// Determines auto reconnect behavior of the runtime.
/// </summary>
AutoReconnectOptions AutoReconnectOptions { get; set; }
}
6 changes: 0 additions & 6 deletions src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs

This file was deleted.

62 changes: 39 additions & 23 deletions src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@
IServiceProvider serviceProvider,
ILogger<NetDaemonRuntime> logger,
ICacheManager cacheManager)
: IRuntime
: INetDaemonRuntime
{
private const string Version = "local build";
private const int TimeoutInSeconds = 5;

private readonly Lock _initializationLock = new();
private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);

private readonly HomeAssistantSettings _haSettings = settings.Value;

private Lazy<Task>? _initializationTask;
private IAppModelContext? _applicationModelContext;
private CancellationToken? _stoppingToken;
private CancellationTokenSource? _runnerCancellationSource;
Expand All @@ -26,11 +30,26 @@
internal IReadOnlyCollection<IApplication> ApplicationInstances =>
_applicationModelContext?.Applications ?? [];

private readonly TaskCompletionSource _startedAndConnected = new();

private Task _runnerTask = Task.CompletedTask;

public async Task StartAsync(CancellationToken stoppingToken)
public Task EnsureInitializedAsync(CancellationToken stoppingToken = default)
{
if (_initializationTask is null)
{
lock (_initializationLock)
{
if (_initializationTask is null)

Check warning on line 41 in src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs

View workflow job for this annotation

GitHub Actions / 🔨 Build sources (CI)

'_initializationTask is null' is always 'true'. Remove or refactor the condition(s) to avoid dead code. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508) [/home/runner/work/netdaemon/netdaemon/src/Runtime/NetDaemon.Runtime/NetDaemon.Runtime.csproj]

Check warning on line 41 in src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs

View workflow job for this annotation

GitHub Actions / 🔨 Build sources (CI)

'_initializationTask is null' is always 'true'. Remove or refactor the condition(s) to avoid dead code. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508) [/home/runner/work/netdaemon/netdaemon/src/Runtime/NetDaemon.Runtime/NetDaemon.Runtime.csproj]
{
_initializationTask = new Lazy<Task>(() => StartAndInitializeAsync(stoppingToken));
}
}
}
return _initializationTask.Value;
}

public AutoReconnectOptions AutoReconnectOptions { get; set; } = AutoReconnectOptions.StopReconnectOnUnAuthorized;

private Task StartAndInitializeAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version);

Expand All @@ -46,6 +65,7 @@
{
_runnerCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);

// Assign the runner so we can dispose it later. Note that this task contains the connection loop and will not end. We don't want to await it.
_runnerTask = homeAssistantRunner.RunAsync(
_haSettings.Host,
_haSettings.Port,
Expand All @@ -56,17 +76,15 @@
_runnerCancellationSource.Token);

// make sure we cancel the task if the stoppingToken is cancelled
stoppingToken.Register(() =>
{
_startedAndConnected.TrySetCanceled();
});
// Make sure we only return after the connection is made and initialization is ready
await _startedAndConnected.Task;
stoppingToken.Register(() => _initializationTcs.TrySetCanceled());

return _initializationTcs.Task;
}
catch (OperationCanceledException)
{
// Ignore and just stop
}
return Task.CompletedTask;

Check warning on line 87 in src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs

View check run for this annotation

Codecov / codecov/patch

src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs#L87

Added line #L87 was not covered by tests
}

private async Task OnHomeAssistantClientConnected(
Expand All @@ -80,7 +98,7 @@
if (_applicationModelContext is not null)
{
// Something wrong with unloading and disposing apps on restart of HA, we need to prevent apps loading multiple times
logger.LogWarning("Applications were not successfully disposed during restart, skippin loading apps again");
logger.LogWarning("Applications were not successfully disposed during restart, skipping loading apps again");

Check warning on line 101 in src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs

View check run for this annotation

Codecov / codecov/patch

src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs#L101

Added line #L101 was not covered by tests
return;
}

Expand All @@ -90,21 +108,18 @@

await LoadNewAppContextAsync(haConnection, cancelToken);

_startedAndConnected.TrySetResult();
// Signal anyone waiting that the runtime is now initialized
_initializationTcs.TrySetResult();
}
catch (Exception ex)
{
if (!_startedAndConnected.Task.IsCompleted)
if (!_initializationTcs.Task.IsCompleted)
{
// This means this was the first time we connected and StartAsync is still awaiting _startedAndConnected
// By setting the exception on the task it will propagate up.
_startedAndConnected.SetException(ex);
}
else
{
// There is none waiting for this event handler to finish so we need to Log the exception here
logger.LogCritical(ex, "Error re-initializing after reconnect to Home Assistant");
_initializationTcs.SetException(ex);
}
logger.LogCritical(ex, "Error (re-)initializing after connect to Home Assistant");
}
}

Expand Down Expand Up @@ -150,13 +165,14 @@
{
logger.LogError(e, "Error disposing applications");
}
IsConnected = false;

if (reason == DisconnectReason.Unauthorized)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code was important since we needed the NetDaemon not to retry connection if it was unauthorized. It should just log and think it is connected. It is a hacky way of doing ti when I see it now but that feature is needed not to make user account locked etc. Unless you handle this somewhere else in the code and I fail to see it.

if (AutoReconnectOptions == AutoReconnectOptions.StopReconnectOnUnAuthorized && reason == DisconnectReason.Unauthorized)
{
// We will exit the runtime if the token is unauthorized to avoid hammering the server
_startedAndConnected.SetResult();
logger.LogInformation("Home Assistant runtime will dispose itself to stop automatic retrying to prevent user from being locked out.");
await DisposeAsync();

Check warning on line 172 in src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs

View check run for this annotation

Codecov / codecov/patch

src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs#L171-L172

Added lines #L171 - L172 were not covered by tests
}

IsConnected = false;
}

private async Task DisposeApplicationsAsync()
Expand Down
Loading
Loading