Skip to content

fix(chore): multiple issues#2580

Merged
pkuehnel merged 16 commits intodevelopfrom
fix/chore
Mar 1, 2026
Merged

fix(chore): multiple issues#2580
pkuehnel merged 16 commits intodevelopfrom
fix/chore

Conversation

@pkuehnel
Copy link
Owner

No description provided.

@pkuehnel pkuehnel requested a review from Copilot February 27, 2026 22:06
@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses several issues related to the SignalR client-side service, focusing on improving its robustness and encapsulation. It ensures proper initialization of SignalR connections in key UI components, handles event subscriptions more safely by allowing null event handlers, and refactors an internal retry policy class to be privately scoped within its containing service. These changes contribute to a more stable and maintainable client-side communication layer.

Highlights

  • SignalR Initialization: The SignalRStateService.InitializeAsync() method is now explicitly called in CarDetailsComponent.razor and ChargingConnectorDetailsComponent.razor to ensure the SignalR connection is properly initialized before state subscriptions are made.
  • Event Nullability: The OnConnectionStateChanged event in the ISignalRStateService interface has been updated to be nullable (Action?), providing better handling for cases where no event subscribers are present.
  • Encapsulation Improvement: The InfiniteRetryPolicy class has been refactored from a public class to a private nested class within SignalRStateService, enhancing encapsulation by limiting its scope to where it is used.
Changelog
  • TeslaSolarCharger/Client/Components/StartPage/CarDetailsComponent.razor
    • Added a call to SignalRStateService.InitializeAsync() before subscribing to state updates.
  • TeslaSolarCharger/Client/Components/StartPage/ChargingConnectorDetailsComponent.razor
    • Introduced a call to SignalRStateService.InitializeAsync() prior to subscribing to state updates.
  • TeslaSolarCharger/Client/Services/Contracts/ISignalRStateService.cs
    • Modified the OnConnectionStateChanged event to be nullable (Action?).
  • TeslaSolarCharger/Client/Services/SignalRStateService.cs
    • Refactored InfiniteRetryPolicy into a private nested class within SignalRStateService.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

The pull request addresses multiple issues, including enabling nullable reference types, improving encapsulation by making a helper class private, and ensuring the SignalR service is initialized before use in components. My review focuses on the approach for initializing the SignalR service. While the current change is a valid fix, I've suggested a refactoring to improve the service's design and reduce code duplication in consumer components.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses a set of small client-side SignalR reliability/cleanup issues in the Blazor client by tightening the SignalR state service contract and ensuring affected components initialize the SignalR connection before subscribing.

Changes:

  • Make InfiniteRetryPolicy an internal implementation detail of SignalRStateService.
  • Mark ISignalRStateService.OnConnectionStateChanged as nullable to match actual usage.
  • Ensure CarDetailsComponent and ChargingConnectorDetailsComponent call InitializeAsync() before subscribing to SignalR state updates.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
TeslaSolarCharger/Client/Services/SignalRStateService.cs Moves retry policy into the service as a private nested class.
TeslaSolarCharger/Client/Services/Contracts/ISignalRStateService.cs Adjusts event nullability to align with nullable-enabled usage.
TeslaSolarCharger/Client/Components/StartPage/ChargingConnectorDetailsComponent.razor Initializes SignalR connection before subscribing to state updates.
TeslaSolarCharger/Client/Components/StartPage/CarDetailsComponent.razor Initializes SignalR connection before subscribing to state updates.
Comments suppressed due to low confidence (2)

TeslaSolarCharger/Client/Components/StartPage/ChargingConnectorDetailsComponent.razor:100

  • The callback passed to Subscribe<...> is declared as async void. With async void, exceptions after the first await bypass the returned Task chain and can surface as unhandled, and the caller cannot await completion. Prefer a non-async callback (and dispatch async work via a safe fire-and-forget helper), or change the service API to accept Func<T, Task> so async callbacks can be awaited/observed.
        await SignalRStateService.Subscribe<DtoChargingConnectorOverviewState>(
            DataTypeConstants.ChargingConnectorOverviewState,
            async void (state) =>
            {
                try

TeslaSolarCharger/Client/Components/StartPage/CarDetailsComponent.razor:193

  • The Subscribe<...> callback is async void, which makes exceptions unobservable and prevents awaiting completion. Prefer a synchronous callback plus explicit safe dispatch, or update the ISignalRStateService.Subscribe contract to accept Func<T, Task> so async handlers can be awaited and errors handled reliably.
        await SignalRStateService.Subscribe<DtoCarOverviewState>(
            DataTypeConstants.CarOverviewState,
            async void (state) =>
            {
                try

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

google-labs-jules bot and others added 3 commits February 27, 2026 23:12
- Added internal `await InitializeAsync()` calls to public methods in `SignalRStateService`.
- Changed `Subscribe` and `SubscribeToTrigger` to return `Task<IDisposable>` for proper cleanup.
- Removed redundant `InitializeAsync()` calls from Blazor components.
- Added proper `IDisposable` implementation in components using SignalR subscriptions to prevent duplicate updates and memory leaks during re-renders.

Co-authored-by: pkuehnel <[email protected]>
…1030221631243904585

refactor: Centralize SignalR initialization and fix subscription leaks
@pkuehnel
Copy link
Owner Author

/gemini review

@pkuehnel pkuehnel requested a review from Copilot February 27, 2026 23:36
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant improvement by properly managing SignalR subscriptions in Blazor components using the IDisposable pattern. This will prevent memory leaks and other issues related to component lifecycle. The centralization of InitializeAsync in SignalRStateService also simplifies the component code.

My review focuses on a critical thread-safety issue in SignalRStateService that needs to be addressed. The rest of the changes look good.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

TeslaSolarCharger/Client/Components/StartPage/NotChargingAtExpectedPowerReasonsComponent.razor:137

  • OnParametersSetAsync runs multiple times; this code overwrites _subscription each time without disposing the previous one, leading to multiple trigger subscriptions and a leak. Dispose the existing subscription before re-subscribing, or only (re)subscribe when the effective key (e.g., CarId/ChargingConnectorId) changes.
    protected override async Task OnParametersSetAsync()
    {
        await base.OnParametersSetAsync();
        
        _subscription = await SignalRStateService.SubscribeToTrigger(
            DataTypeConstants.NotChargingAsExpectedChangeTrigger,
            async void () =>
            {
                try
                {
                    await RefreshNotChargingWithExpectedPowerReasons();
                    await InvokeAsync(StateHasChanged).ConfigureAwait(false);
                }
                catch (Exception e)
                {
                    Logger.LogError(e, "{method}() failed", nameof(RefreshNotChargingWithExpectedPowerReasons));
                }
            });

        await RefreshNotChargingWithExpectedPowerReasons();
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

pkuehnel and others added 3 commits February 28, 2026 00:51
…rametersSetAsync

Moved SignalR subscription logic from `OnInitializedAsync` to `OnParametersSetAsync` in `LoadpointComponent.razor`. By comparing the new `entityKey` with `_lastSubscribedKey`, the component correctly disposes the previous subscription and subscribes to the new topic whenever the Blazor framework reuses the component instance for a different LoadPoint (e.g. in list rendering). This prevents stale data binding and updates applying to the wrong row.

Co-authored-by: pkuehnel <[email protected]>
…cle-8357064537335588501

Fix: Handle parameter changes in LoadpointComponent SignalR subscriptions
@pkuehnel pkuehnel requested a review from Copilot February 28, 2026 00:06
@pkuehnel
Copy link
Owner Author

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant improvement by properly managing SignalR subscriptions using the IDisposable pattern across several Blazor components. This is a great step towards preventing memory leaks and redundant subscriptions. The related refactoring in SignalRStateService is mostly well-executed, but I've identified a couple of potential thread-safety issues regarding how subscriber lists are modified. My review comments include specific suggestions to address these concurrency concerns to make the implementation more robust. Overall, this is a valuable change that enhances the stability and performance of the client application.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@pkuehnel
Copy link
Owner Author

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces significant improvements to resource management by implementing the IDisposable interface in several Blazor components and the SignalRStateService. This ensures that SignalR subscriptions are properly disposed of when components are no longer active, preventing potential memory leaks and improving application stability. The SignalRStateService has been enhanced to return IDisposable for subscriptions and to automatically initialize the connection when necessary, making it more robust. A new SubscriptionDisposable class has been added to facilitate this cleanup. Overall, these changes contribute to a more maintainable and efficient codebase.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

TeslaSolarCharger/Client/Components/StartPage/NotChargingAtExpectedPowerReasonsComponent.razor:134

  • OnParametersSetAsync assigns _subscription = await ...SubscribeToTrigger(...) on every parameter update, overwriting the previous subscription without disposing it. This can create duplicate trigger subscriptions and leak callbacks until component disposal. Consider subscribing only once (e.g., when _subscription == null) or disposing the existing subscription before re-subscribing.
        _subscription = await SignalRStateService.SubscribeToTrigger(
            DataTypeConstants.NotChargingAsExpectedChangeTrigger,
            async void () =>
            {
                try
                {
                    await RefreshNotChargingWithExpectedPowerReasons();
                    await InvokeAsync(StateHasChanged).ConfigureAwait(false);
                }
                catch (Exception e)
                {
                    Logger.LogError(e, "{method}() failed", nameof(RefreshNotChargingWithExpectedPowerReasons));
                }
            });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@pkuehnel pkuehnel requested a review from Copilot February 28, 2026 13:45
@pkuehnel
Copy link
Owner Author

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces significant improvements to the SignalR state management by implementing a disposable subscription pattern, which prevents memory leaks and enhances robustness. The changes correctly handle component lifecycle events to manage subscriptions. Additionally, several threading issues in SignalRStateService are resolved, making the service more thread-safe. I've identified a potential race condition in GetStateAsync that could lead to inconsistent state on the client, which I've detailed in a specific comment.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

TeslaSolarCharger/Client/Services/SignalRStateService.cs:158

  • Subscribe adds the callback to the in-memory subscriber list before EnsureSubscribedToDataType. If EnsureSubscribedToDataType throws (e.g., connection not established), the callback remains registered, which can leak memory and produce duplicate notifications if the caller retries. Wrap the subscription flow in try/catch and remove the callback on failure, or only add the callback after the data-type subscription succeeds.
    public async Task<IDisposable> Subscribe<T>(string dataType, Action<T> callback, string entityId = "") where T : class
    {
        await InitializeAsync();
        _logger.LogTrace("{method}<{type}>({dataType}, callback, {entityId})", nameof(Subscribe), typeof(T), dataType, entityId);
        var key = _entityKeyGenerationHelper.GetDataKey(dataType, entityId);

        Action<object> wrappedCallback = obj => { if (obj is T typedObj) callback(typedObj); };

        // Add the callback
        var subscribersList = _subscribers.GetOrAdd(key, _ => new List<Action<object>>());
        lock (subscribersList)
        {
            subscribersList.Add(wrappedCallback);
        }

        // Subscribe to the data type if not already subscribed
        await EnsureSubscribedToDataType(dataType);

TeslaSolarCharger/Client/Components/StartPage/NotChargingAtExpectedPowerReasonsComponent.razor:134

  • OnParametersSetAsync re-subscribes every time it runs but overwrites _subscription without disposing the previous subscription. This can accumulate multiple active trigger subscriptions over time. Dispose the existing _subscription before assigning a new one (or subscribe once in OnInitialized/guard with a boolean).
    private IDisposable? _subscription;

    protected override async Task OnParametersSetAsync()
    {
        await base.OnParametersSetAsync();
        
        _subscription = await SignalRStateService.SubscribeToTrigger(
            DataTypeConstants.NotChargingAsExpectedChangeTrigger,
            async void () =>
            {
                try
                {
                    await RefreshNotChargingWithExpectedPowerReasons();
                    await InvokeAsync(StateHasChanged).ConfigureAwait(false);
                }
                catch (Exception e)
                {
                    Logger.LogError(e, "{method}() failed", nameof(RefreshNotChargingWithExpectedPowerReasons));
                }
            });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@pkuehnel pkuehnel requested a review from Copilot February 28, 2026 13:59
@pkuehnel
Copy link
Owner Author

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces significant improvements by fixing memory leaks related to SignalR subscriptions and enhancing the thread safety of SignalRStateService. The use of IDisposable for subscriptions and robust handling of component lifecycles is well-implemented. I've found a potential race condition in the InitializeAsync method's logic that could cause issues after a successful auto-reconnection following a failed connection attempt. My review includes a suggestion to address this.

Comment on lines 44 to 93
try
{
if (_hubConnection != null)
// 1. Create the hub connection object if it doesn't exist yet
if (_hubConnection == null)
{
return;
}
_hubConnection = new HubConnectionBuilder()
.WithUrl(_navigationManager.ToAbsoluteUri("/appStateHub"))
.WithAutomaticReconnect(new InfiniteRetryPolicy(5))
.Build();

_hubConnection = new HubConnectionBuilder()
.WithUrl(_navigationManager.ToAbsoluteUri("/appStateHub"))
.WithAutomaticReconnect(new InfiniteRetryPolicy(5))
.Build();
_hubConnection.On<StateUpdateDto>(nameof(IAppStateClient.ReceiveStateUpdate), HandleStateUpdate);
_hubConnection.On<string, string>(nameof(IAppStateClient.ReceiveInitialState), HandleInitialState);

_hubConnection.On<StateUpdateDto>(nameof(IAppStateClient.ReceiveStateUpdate), HandleStateUpdate);
_hubConnection.On<string, string>(nameof(IAppStateClient.ReceiveInitialState), HandleInitialState);
_hubConnection.Reconnecting += (error) =>
{
_logger.LogWarning(error, "SignalR connection lost, reconnecting...");
OnConnectionStateChanged?.Invoke();
return Task.CompletedTask;
};

_hubConnection.Reconnecting += (error) =>
{
_logger.LogWarning(error, "SignalR connection lost, reconnecting...");
OnConnectionStateChanged?.Invoke();
return Task.CompletedTask;
};
_hubConnection.Reconnected += async (connectionId) =>
{
_logger.LogInformation("SignalR reconnected with ID: {ConnectionId}", connectionId);
await ResubscribeToAllDataTypes();
await RefreshAllStates();
OnConnectionStateChanged?.Invoke();
};

_hubConnection.Closed += (error) =>
{
_logger.LogWarning(error, "SignalR connection closed");
OnConnectionStateChanged?.Invoke();
return Task.CompletedTask;
};
}

_hubConnection.Reconnected += async (connectionId) =>
// 2. Start the connection ONLY if it is completely disconnected.
if (_hubConnection.State == HubConnectionState.Disconnected)
{
_logger.LogInformation("SignalR reconnected with ID: {ConnectionId}", connectionId);
// Assign the running task to our tracking variable
_connectionTask = StartConnectionInternalAsync();
}

await ResubscribeToAllDataTypes();
await RefreshAllStates();
OnConnectionStateChanged?.Invoke();
};
// 3. Capture the task locally so we can await it outside the lock
taskToAwait = _connectionTask;
}
finally
{
_connectionLock.Release();
}

Choose a reason for hiding this comment

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

high

There's a potential race condition here. If a manual connection attempt via InitializeAsync fails, _connectionTask will hold a faulted task. If SignalR's automatic reconnect then succeeds, the hub state will become Connected. However, the next call to InitializeAsync will not update the faulted _connectionTask, and will attempt to await it, causing an exception even though the connection is healthy.

To fix this, we should check if the connection is already Connected and reset the _connectionTask if it's in a faulted state before proceeding.

        try
        {
            // 1. Create the hub connection object if it doesn't exist yet
            if (_hubConnection == null)
            {
                _hubConnection = new HubConnectionBuilder()
                    .WithUrl(_navigationManager.ToAbsoluteUri("/appStateHub"))
                    .WithAutomaticReconnect(new InfiniteRetryPolicy(5))
                    .Build();

                _hubConnection.On<StateUpdateDto>(nameof(IAppStateClient.ReceiveStateUpdate), HandleStateUpdate);
                _hubConnection.On<string, string>(nameof(IAppStateClient.ReceiveInitialState), HandleInitialState);

                _hubConnection.Reconnecting += (error) =>
                {
                    _logger.LogWarning(error, "SignalR connection lost, reconnecting...");
                    OnConnectionStateChanged?.Invoke();
                    return Task.CompletedTask;
                };

                _hubConnection.Reconnected += async (connectionId) =>
                {
                    _logger.LogInformation("SignalR reconnected with ID: {ConnectionId}", connectionId);
                    await ResubscribeToAllDataTypes();
                    await RefreshAllStates();
                    OnConnectionStateChanged?.Invoke();
                };

                _hubConnection.Closed += (error) =>
                {
                    _logger.LogWarning(error, "SignalR connection closed");
                    OnConnectionStateChanged?.Invoke();
                    return Task.CompletedTask;
                };
            }

            // If a previous connection attempt failed but auto-reconnect succeeded, the connection is now 'Connected' 
            // but '_connectionTask' might still be faulted. We should not await a faulted task if the connection is healthy.
            if (_hubConnection.State == HubConnectionState.Connected && _connectionTask?.IsFaulted == true)
            {
                _connectionTask = null; // Reset the faulted task, so we don't await it.
            }

            // 2. Start the connection ONLY if it is completely disconnected.
            if (_hubConnection.State == HubConnectionState.Disconnected)
            {
                // Assign the running task to our tracking variable
                _connectionTask = StartConnectionInternalAsync();
            }

            // 3. Capture the task locally so we can await it outside the lock
            taskToAwait = _connectionTask;
        }
        finally
        {
            _connectionLock.Release();
        }

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 101 to 106
// 5. Throw a clear error if the connection is Reconnecting or failed,
// satisfying the requirement that it only returns when fully usable.
if (_hubConnection?.State != HubConnectionState.Connected)
{
throw new InvalidOperationException($"SignalR is not usable. Current state: {_hubConnection?.State}");
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

InitializeAsync throws whenever _hubConnection.State is not Connected after awaiting _connectionTask. During automatic reconnect (HubConnectionState.Reconnecting/Connecting), _connectionTask may already be completed from the original start, so callers can get an InvalidOperationException even though the connection may recover moments later. Instead of throwing on Reconnecting, consider awaiting a reconnection completion signal (or polling with a timeout) and only throwing after an actual start/reconnect failure.

Copilot uses AI. Check for mistakes.
@pkuehnel
Copy link
Owner Author

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces crucial fixes for memory leaks in several Blazor components by correctly managing SignalR subscriptions. Implementing IDisposable and ensuring subscriptions are disposed of is a great improvement. The refactoring of SignalRStateService to improve thread safety and connection initialization robustness is also excellent.

I've found one issue in NotChargingAtExpectedPowerReasonsComponent where subscriptions could still leak, and I've left a comment with a suggestion to fix it.

Overall, these are very good changes that will improve the stability and performance of the application.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

TeslaSolarCharger/Client/Components/StartPage/NotChargingAtExpectedPowerReasonsComponent.razor:134

  • OnParametersSetAsync re-subscribes every time parameters are set and overwrites _subscription without disposing the previous one. This can leak subscriptions and cause duplicate callbacks/state refreshes. Dispose the existing _subscription before re-subscribing, or move the subscription to OnInitialized{Async} and only resubscribe when relevant parameters (CarId/ChargingConnectorId) actually change.
    protected override async Task OnParametersSetAsync()
    {
        await base.OnParametersSetAsync();
        
        _subscription = await SignalRStateService.SubscribeToTrigger(
            DataTypeConstants.NotChargingAsExpectedChangeTrigger,
            async void () =>
            {
                try
                {
                    await RefreshNotChargingWithExpectedPowerReasons();
                    await InvokeAsync(StateHasChanged).ConfigureAwait(false);
                }
                catch (Exception e)
                {
                    Logger.LogError(e, "{method}() failed", nameof(RefreshNotChargingWithExpectedPowerReasons));
                }
            });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 105 to 108
if (_hubConnection?.State != HubConnectionState.Connected)
{
throw new InvalidOperationException($"SignalR is not usable. Current state: {_hubConnection?.State}");
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

InitializeAsync throws if the hub state isn’t exactly Connected after leaving the lock. If the connection is in Connecting/Reconnecting (e.g., during automatic reconnect), this will raise an InvalidOperationException and can break callers like GetStateAsync/Subscribe* during transient network hiccups. Consider waiting for the connection to reach Connected (e.g., via a TaskCompletionSource tied to Reconnected/Closed), or treat Connecting/Reconnecting as a non-error and return without throwing (leaving subsequent operations to retry).

Suggested change
if (_hubConnection?.State != HubConnectionState.Connected)
{
throw new InvalidOperationException($"SignalR is not usable. Current state: {_hubConnection?.State}");
}
// Only treat a definitively unusable connection as an error. Allow Connecting/Reconnecting
// states to proceed without throwing so callers can retry during transient hiccups.
if (_hubConnection == null || _hubConnection.State == HubConnectionState.Disconnected)
{
throw new InvalidOperationException($"SignalR is not usable. Current state: {_hubConnection?.State}");
}
if (_hubConnection.State != HubConnectionState.Connected)
{
_logger.LogWarning("SignalR connection not yet fully established. Current state: {State}", _hubConnection.State);
}

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +18
private bool _disposed;
public SubscriptionDisposable(Action unsubscribeAction)
{
_unsubscribeAction = unsubscribeAction ?? throw new ArgumentNullException(nameof(unsubscribeAction));
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

SubscriptionDisposable.Dispose() isn’t thread-safe: if Dispose() is called concurrently, _unsubscribeAction() can run multiple times because _disposed isn’t updated atomically. Consider using Interlocked.Exchange (or a lock) to ensure the unsubscribe action is executed at most once.

Suggested change
private bool _disposed;
public SubscriptionDisposable(Action unsubscribeAction)
{
_unsubscribeAction = unsubscribeAction ?? throw new ArgumentNullException(nameof(unsubscribeAction));
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
private int _disposed; // 0 = not disposed, 1 = disposed
public SubscriptionDisposable(Action unsubscribeAction)
{
_unsubscribeAction = unsubscribeAction ?? throw new ArgumentNullException(nameof(unsubscribeAction));
}
public void Dispose()
{
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1)
{
return;
}

Copilot uses AI. Check for mistakes.
Comment on lines +205 to +214
return new SubscriptionDisposable(() =>
{
if (_subscribers.TryGetValue(key, out var list))
{
lock (list)
{
list.Remove(wrappedCallback);
}
}
});
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

Unsubscribing only removes the callback from the list but leaves the (potentially empty) list stored in _subscribers. Over time this can retain keys indefinitely and also keeps TryGetValue/ContainsKey behavior “true” even when there are no subscribers. Consider removing the dictionary entry when the list becomes empty after removal (e.g., inside the lock).

Copilot uses AI. Check for mistakes.
{
lock (list)
{
list.Remove(callback);
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

Unsubscribing from _triggerSubscribers removes the callback but leaves an empty list in the dictionary, so _triggerSubscribers.ContainsKey(key) can remain true even when there are no trigger subscribers. Consider removing the key when the list becomes empty to avoid unbounded growth and to keep trigger-detection accurate.

Suggested change
list.Remove(callback);
list.Remove(callback);
if (list.Count == 0)
{
_triggerSubscribers.TryRemove(key, out _);
}

Copilot uses AI. Check for mistakes.
@pkuehnel
Copy link
Owner Author

pkuehnel commented Mar 1, 2026

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces significant improvements to the SignalR state management, making it more robust and resilient. The introduction of IDisposable subscriptions and proper lifecycle management in components is a great step towards preventing memory leaks. The connection handling in SignalRStateService is also much more advanced now, with non-blocking initialization and background retries. I've found one issue related to subscription handling in one of the components, which I've detailed in a specific comment.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

TeslaSolarCharger/Client/Components/StartPage/NotChargingAtExpectedPowerReasonsComponent.razor:134

  • OnParametersSetAsync() assigns a new _subscription each time it runs, but it doesn't dispose the previous subscription before overwriting. Since OnParametersSetAsync() can run multiple times (e.g., when CarId/ChargingConnectorId change), this can accumulate multiple active trigger subscriptions and leak callbacks. Dispose the old _subscription (or subscribe once in OnInitialized[Async] if appropriate) before creating a new one.
    protected override async Task OnParametersSetAsync()
    {
        await base.OnParametersSetAsync();
        
        _subscription = await SignalRStateService.SubscribeToTrigger(
            DataTypeConstants.NotChargingAsExpectedChangeTrigger,
            async void () =>
            {
                try
                {
                    await RefreshNotChargingWithExpectedPowerReasons();
                    await InvokeAsync(StateHasChanged).ConfigureAwait(false);
                }
                catch (Exception e)
                {
                    Logger.LogError(e, "{method}() failed", nameof(RefreshNotChargingWithExpectedPowerReasons));
                }
            });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 126 to 127
if (_isRetryingInitialConnection) return;
_isRetryingInitialConnection = true;
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

StartBackgroundRetryAsync() uses _isRetryingInitialConnection as a non-atomic guard (if then set). If multiple callers hit this concurrently, it can start multiple background retry loops. Use an atomic guard (e.g., Interlocked.CompareExchange) or a lock to ensure only one retry task can be started at a time.

Suggested change
if (_isRetryingInitialConnection) return;
_isRetryingInitialConnection = true;
lock (this)
{
if (_isRetryingInitialConnection) return;
_isRetryingInitialConnection = true;
}

Copilot uses AI. Check for mistakes.
Comment on lines +115 to +120
_logger.LogWarning(ex, "Failed to establish initial SignalR connection. Starting background retry...");

// Fire-and-forget the retry task. Notice the discard (_)
// We do NOT await this, so the lock in InitializeAsync is released immediately.
_ = StartBackgroundRetryAsync();
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

StartConnectionInternalAsync() fire-and-forgets StartBackgroundRetryAsync() without observing the returned task. If StartBackgroundRetryAsync() throws (e.g., ObjectDisposedException from _connectionLock.WaitAsync() during shutdown), it becomes an unobserved exception. Consider catching/handling exceptions inside StartBackgroundRetryAsync() (especially cancellation/dispose) and/or tracking the retry task so it can be awaited/cancelled during DisposeAsync().

Copilot uses AI. Check for mistakes.
Comment on lines 24 to 25
private const int ConnectionRetryIntervallMilliseconds = 5000;
private bool _isRetryingInitialConnection = false;
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

The identifier ConnectionRetryIntervallMilliseconds has a spelling error (Intervall). Renaming to ConnectionRetryIntervalMilliseconds (and updating usages) would improve readability and avoid propagating the typo.

Copilot uses AI. Check for mistakes.
@pkuehnel pkuehnel merged commit ba7df47 into develop Mar 1, 2026
6 checks passed
@pkuehnel pkuehnel deleted the fix/chore branch March 1, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants