I am using simple hook to send messages to the client from the server(particular user thats logged in) I noticed that user is able to open url in different tab which should be perfectly possible, however I am not happy about the fact it counts as an extra connection to azure signalr(cost issue), I was trying to close the old connection once the user opens the url in different tab. I can easily identify the user, initially I was trying to store the connection inside a dictionary but I noticed a weird pattern where every odd-numbered new tab (e.g., 1st, 3rd, 5th) does not disconnect the previous connection, while every even-numbered tab (e.g., 2nd, 4th) does. I tried to use memory cache instead of dictionary but it is behaving exactly like before.
<script>
const userIdentifier = '@objectIdClaim';//users unique id
const connection = new signalR.HubConnectionBuilder()
.withUrl(`/webhub?username=${userIdentifier}`, { accessTokenFactory: () => this.loginToken })
.configureLogging(signalR.LogLevel.Trace)
.build();
// Handle server messages
connection.on("SendMessageToUser", async (message) => {
console.log("Received message from server:", message);
});
//handle opening new tab
connection.on("ForceDisconnect", () => {
connection.stop(); // Stop the SignalR connection
showNotification("You have been disconnected, due to the new login");
});
// Stop connection when the user leaves the page
window.addEventListener("beforeunload", async () => {
await connection.stop();
console.log("SignalR web connection stopped.");
});
// Start the connection
(async () => {
try {
await connection.start();
console.log("SignalR connection established.");
} catch (e) {
console.error("SignalR connection error:", e.toString());
}
})();
function showNotification(message) {
//pops up modal
}
</script>
My web hub c# code
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Something
{
[Authorize]
public class Webhub : Hub
{
// track user connections
private readonly IMemoryCache _cache;
public Webhub(IMemoryCache cache)
{
_cache = cache;
}
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
if (userId == null)
{
// No UserIdentifier available, disconnect this connection
Context.Abort();
return;
}
var connectionId = Context.ConnectionId;
// Check if the user already has an active connection
if (_cache.TryGetValue(userId, out string existingConnectionId))
{
if (existingConnectionId != connectionId) // New connection attempt
{
Debug.WriteLine($"User {userId} connected BEFORE connection ID {existingConnectionId} connecting NOW {connectionId}");
// Notify and disconnect the old connection
await DisconnectOldConnection(existingConnectionId);
}
}
_cache.Set(userId, connectionId, TimeSpan.FromHours(1));
Debug.WriteLine($"User {userId} connected with connection ID {connectionId}.");
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.UserIdentifier;
if (userId != null)
{
// Remove the user's connection when they disconnect
_cache.Remove(userId);
Debug.WriteLine($"User {userId} disconnected.");
}
await base.OnDisconnectedAsync(exception);
}
private async Task DisconnectOldConnection(string connectionId)
{
// This will disconnect the client with the given connectionId
// The client handles disconnection on the client-side
await Clients.Client(connectionId).SendAsync("ForceDisconnect");
}
}
}
here are the logs from this weird pattern
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID Ii9pOdJti4lRyA8QdG5zWAZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected BEFORE connection ID Ii9pOdJti4lRyA8QdG5zWAZq3viAr02 connecting NOW fUpzQDu-Itg7yu-z4reQ_QZq3viAr02
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID fUpzQDu-Itg7yu-z4reQ_QZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID R_qLkwKtxuE7nLE0TniVbQZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected BEFORE connection ID R_qLkwKtxuE7nLE0TniVbQZq3viAr02 connecting NOW t3mNN20gUdhOK-UXIH5TQQZq3viAr02
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID t3mNN20gUdhOK-UXIH5TQQZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID F9ne7QxXFTH1DZYs3lvVOQZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected BEFORE connection ID F9ne7QxXFTH1DZYs3lvVOQZq3viAr02 connecting NOW 645t2jvOMbLgeJCVp6Gm5QZq3viAr02
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID 645t2jvOMbLgeJCVp6Gm5QZq3viAr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected.
Any ideas or help would be appreciated, what could be causing this or is there another way of doing that? Maybe storing that in the database? Also not sure how that Webhub class is instantiated this is the way I register it in Startup.cs so maybe some scope issue?
services.AddSignalR().AddAzureSignalR(Configuration["SignalUrl"]);
services.AddSingleton<IUserIdProvider, MyCustomProvider>();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapRazorPages();
endpoints.MapHub<Webhub>("/webhub");
});
I managed to do this the following way, memory cache can be replaced by redis or anything you want
[Authorize]
public class Webhub : Hub
{
private readonly IMemoryCache _cache;
public Webhub(IMemoryCache cache)
{
_cache = cache;
}
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
if (userId == null)
{
Context.Abort();
return;
}
var connectionId = Context.ConnectionId;
// Fetch or initialize the list of connections for the user
var connections = _cache.GetOrCreate(userId, entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);//adjust
return new List<string>();
});
// Ensure thread safety while updating the list
lock (connections)
{
// Call DisconnectOldConnections to handle any active connections
if (connections.Count > 0)
{
_ = DisconnectOldConnections(userId);
}
// Add the new connection
connections.Add(connectionId);
}
Console.WriteLine($"User {userId} connected with connection ID {connectionId}.");
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.UserIdentifier;
if (userId == null) return;
if (_cache.TryGetValue(userId, out List<string>? connections))
{
lock (connections)
{
connections.Remove(Context.ConnectionId);
if (connections.Count == 0)
{
// Remove the user from the cache when all connections are closed
_cache.Remove(userId);
}
}
Console.WriteLine($"User {userId} disconnected. Connection ID {Context.ConnectionId} removed.");
}
await base.OnDisconnectedAsync(exception);
}
private async Task DisconnectOldConnections(string userId)
{
if (_cache.TryGetValue(userId, out List<string>? connections))
{
List<string> connectionsToDisconnect;
lock (connections)
{
connectionsToDisconnect = new List<string>(connections);
connections.Clear(); // Clear the connections for this user
}
// Notify all old connections to disconnect
foreach (var connectionId in connectionsToDisconnect)
{
try
{
await Clients.Client(connectionId).SendAsync("ForceDisconnect");
Console.WriteLine($"Disconnected old connection {connectionId} for user {userId}.");
}
catch (Exception ex)
{
Console.WriteLine($"Error disconnecting connection {connectionId}: {ex.Message}");
}
}
}
}
}
and simply on the client side
connection.on("ForceDisconnect", () => {
connection.stop(); // Stop the SignalR connection
showNotification("You have been disconnected, due to the new login");
//can redirect or close window here
});
also good to not use automatic reconnect because it would connect again and disconnect other window etc
Tested that and it works fine even window opened in different browser
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID yLsZQe_W2OkNsrqoiK2aVwbSt_dwr02.
Disconnected old connection yLsZQe_W2OkNsrqoiK2aVwbSt_dwr02 for user f1091f94-ae9b-42ad-a96c-3ed2ab309938.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID 55qCVj8n8eFusbkN9reelgbSt_dwr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected. Connection ID yLsZQe_W2OkNsrqoiK2aVwbSt_dwr02 removed.
Disconnected old connection 55qCVj8n8eFusbkN9reelgbSt_dwr02 for user f1091f94-ae9b-42ad-a96c-3ed2ab309938.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID OujWBtMtUnvY0QySE4MCsQbSt_dwr02.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 disconnected. Connection ID 55qCVj8n8eFusbkN9reelgbSt_dwr02 removed.
Disconnected old connection OujWBtMtUnvY0QySE4MCsQbSt_dwr02 for user f1091f94-ae9b-42ad-a96c-3ed2ab309938.
User f1091f94-ae9b-42ad-a96c-3ed2ab309938 connected with connection ID NKbPBQ1EcT0KVA4oDcFxZAbSt_dwr02.