Тема: автоматичний рефреш сторінки після рестарту IIS App Pool у грі в шашки
Опис
Є ASP.NET Core проект для онлайн‑гри в шашки.
Проблема: після перезапуску пулу IIS усі WebSocket‑з’єднання обриваються.
Клієнт бачить повідомлення “Reconnecting…”, але фігури не можуть ходити.
Гра зависає, і щоб продовжити, користувачеві доводиться вручну оновлювати сторінку (F5).
Стан гри (дошка, рахунок, таймери) зберігається у Local Storage та на сервері, але не підтягується автоматично після перепідключення.
Очікувана поведінка
Після рестарту IIS App Pool клієнт має автоматично перепідключитися до SignalR, отримати актуальний стан кімнати й продовжити гру без ручного рефрешу сторінки.
Технічні деталі
ASP.NET Core SignalR, хостинг під IIS.
Використовую OnDisconnectedAsync для очищення ConnectionId та таймер на повторне підключення.
На клієнті налаштовано withAutomaticReconnect().
Проблема: після recycle клієнт підключається, але події від хаба не доходять, і хід фігур неможливий без ручного refresh.
Дошка і шашки після рестарту пулу мають бути на своїх місцях і бути активними, що можливо тільки після рефрешу сторінки вручну.
Запитання
Як правильно реалізувати автоматичний рефреш/перепідключення після перезапуску IIS App Pool, щоб:
клієнт не потребував ручного оновлення сторінки;
SignalR‑хаб автоматично відновлював стан гри;
фігури могли ходити одразу після reconnect
//checkershub.cs
using Microsoft.AspNetCore.SignalR;
using CheckersGame.Services;
using CheckersGame.Models;
namespace CheckersGame.Hubs;
public class CheckersHub : Hub
{
private readonly GameService _game;
private readonly ILogger<CheckersHub> _logger;
public CheckersHub(GameService game, ILogger<CheckersHub> logger)
{
_game = game;
_logger = logger;
}
public async Task JoinGame(PlayerInfo player)
{
if (player == null || string.IsNullOrEmpty(player.EntityId))
{
await Clients.Caller.SendAsync("nameTaken");
return;
}
player.Id = Context.ConnectionId;
player.ConnectionId = Context.ConnectionId;
_logger.LogInformation("\U0001f3ae JoinGame: {name}({eid}) connId={cid}",
player.PlayerName, player.EntityId, Context.ConnectionId);
_game.CancelDisconnectTimer(player.EntityId);
// ── Reconnect to existing room ─────────────────────────────────────
var existingRoom = _game.FindRoomByEntityId(player.EntityId);
if (existingRoom != null)
{
_logger.LogInformation("\u267b\ufe0f RECONNECT: room {room} for {eid}",
existingRoom.RoomName, player.EntityId);
if (existingRoom.Player1.EntityId == player.EntityId)
{
existingRoom.Player1.Id = Context.ConnectionId;
existingRoom.Player1.ConnectionId = Context.ConnectionId;
}
else
{
existingRoom.Player2.Id = Context.ConnectionId;
existingRoom.Player2.ConnectionId = Context.ConnectionId;
}
await Groups.AddToGroupAsync(Context.ConnectionId, existingRoom.RoomName);
await Clients.Caller.SendAsync("joinedRoom", existingRoom.RoomName);
var state = _game.GetLatestGameState(existingRoom.RoomName);
var resumePayload = new
{
players = new[] { existingRoom.Player1, existingRoom.Player2 },
roomName = existingRoom.RoomName,
boardState = state?.BoardState ?? "",
currentPlayer = state?.CurrentPlayer ?? 0,
player1Score = state?.Player1Score ?? 0,
player2Score = state?.Player2Score ?? 0,
lastMoveTime = state?.LastMoveTime ?? DateTime.UtcNow.ToString("O")
};
await Clients.Caller.SendAsync("resumeGame", resumePayload);
bool p1Connected = !string.IsNullOrEmpty(existingRoom.Player1.ConnectionId);
bool p2Connected = !string.IsNullOrEmpty(existingRoom.Player2.ConnectionId);
if (p1Connected && p2Connected)
{
_logger.LogInformation("\u2705 Both players reconnected in {room} \u2014 scheduling bothPlayersReady", existingRoom.RoomName);
var capturedRoom = existingRoom;
var capturedState = state;
// FIX: Get IHubContext from DI BEFORE the delay
// Hub instance is disposed after JoinGame returns, so Clients is dead inside Task.Delay
var hubContext = Context.GetHttpContext()!.RequestServices
.GetRequiredService<IHubContext<CheckersHub>>();
_ = Task.Delay(1200).ContinueWith(async _ =>
{
try
{
var freshState = _game.GetLatestGameState(capturedRoom.RoomName);
await hubContext.Clients.Group(capturedRoom.RoomName).SendAsync("bothPlayersReady", new
{
players = new[] { capturedRoom.Player1, capturedRoom.Player2 },
roomName = capturedRoom.RoomName,
boardState = freshState?.BoardState ?? capturedState?.BoardState ?? "",
currentPlayer = freshState?.CurrentPlayer ?? capturedState?.CurrentPlayer ?? 0,
player1Score = freshState?.Player1Score ?? capturedState?.Player1Score ?? 0,
player2Score = freshState?.Player2Score ?? capturedState?.Player2Score ?? 0,
lastMoveTime = freshState?.LastMoveTime ?? capturedState?.LastMoveTime ?? DateTime.UtcNow.ToString("O")
});
_logger.LogInformation("\U0001f4e1 bothPlayersReady sent to room {room}", capturedRoom.RoomName);
}
catch (Exception ex) { _logger.LogError("bothPlayersReady error: {msg}", ex.Message); }
});
}
else
{
await Clients.OthersInGroup(existingRoom.RoomName)
.SendAsync("opponentReconnected", new { entityId = player.EntityId });
}
_logger.LogInformation("\U0001f4e6 resumeGame sent for {room}", existingRoom.RoomName);
return;
}
// ── Guard: prevent duplicate join ──────────────────────────────────
if (_game.IsWaiting(player.EntityId) || _game.IsInRoom(player.EntityId))
{
await Clients.Caller.SendAsync("nameTaken");
return;
}
if (_game.IsExpired(player.EntityId))
{
_logger.LogInformation("\u26a0\ufe0f IsExpired for {eid} \u2014 clearing, letting player rejoin", player.EntityId);
_game.ClearExpired(player.EntityId);
}
// ── Add to matchmaking queue ───────────────────────────────────────
_game.AddWaitingPlayer(player);
_logger.LogInformation("\u23f3 WAITING: {eid}", player.EntityId);
var (p1, p2) = _game.TryMatchWaiting();
if (p1 != null && p2 != null)
await MatchPlayers(p1, p2);
}
public async Task GroupGame(PlayerInfo player, string inviteRoom)
{
if (string.IsNullOrEmpty(player.EntityId)) return;
player.Id = Context.ConnectionId;
player.ConnectionId = Context.ConnectionId;
var groupName = inviteRoom.Split('?')[0];
player.InviteRoom = groupName;
if (!_game.IsInGroup(player.EntityId, groupName))
_game.AddGroupPlayer(player);
var members = _game.GetGroupPlayers(groupName);
if (members.Count >= 2)
await MatchPlayers(members[0], members[1]);
else
await Clients.Caller.SendAsync("waitingGroupMember",
new[] { new { entityId = 111, name = "Waiting for your invited friend" } });
}
public async Task SaveState(string roomName, string boardState, int currentPlayer, int p1Score, int p2Score)
{
var room = _game.GetRoom(roomName);
if (room == null) return;
_game.UpsertGameState(roomName, boardState, currentPlayer, p1Score, p2Score);
_logger.LogInformation("\U0001f4be State saved: {room}", roomName);
}
public async Task Move(object moveData)
{
var room = _game.FindRoomByConnectionId(Context.ConnectionId);
if (room == null) return;
await Clients.OthersInGroup(room.RoomName).SendAsync("opponentMove", moveData);
}
public async Task MoveClick(object moveData)
{
var room = _game.FindRoomByConnectionId(Context.ConnectionId);
if (room == null) return;
await Clients.OthersInGroup(room.RoomName).SendAsync("opponentMove_click", moveData);
}
public async Task SendEmoji(string emojiName)
{
var room = _game.FindRoomByConnectionId(Context.ConnectionId);
if (room == null) return;
await Clients.OthersInGroup(room.RoomName).SendAsync("sendEmoji", emojiName);
}
public async Task Updatetimer(object timer)
{
var room = _game.FindRoomByConnectionId(Context.ConnectionId);
if (room == null) return;
await Clients.OthersInGroup(room.RoomName).SendAsync("updatetimer", timer);
}
public async Task ActiveStatus(bool status)
{
var room = _game.FindRoomByConnectionId(Context.ConnectionId);
if (room == null) return;
await Clients.OthersInGroup(room.RoomName).SendAsync("active_status", status);
}
public async Task Giveup(int playerName)
{
var room = _game.FindRoomByConnectionId(Context.ConnectionId);
if (room == null) return;
await Clients.Group(room.RoomName).SendAsync("giveup", playerName);
}
public async Task Toggleuser(object status)
{
var room = _game.FindRoomByConnectionId(Context.ConnectionId);
if (room == null) return;
await Clients.OthersInGroup(room.RoomName).SendAsync("toggleuser", status);
}
public async Task DisconnectGame()
{
_game.RemoveWaitingByConnectionId(Context.ConnectionId);
_game.RemoveGroupByConnectionId(Context.ConnectionId);
var room = _game.FindRoomByConnectionId(Context.ConnectionId);
if (room == null) return;
await Clients.OthersInGroup(room.RoomName).SendAsync("playerDisconnected", room.RoomName);
_game.RemoveRoom(room.RoomName);
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("\u274c Disconnected: {cid}", Context.ConnectionId);
_game.RemoveWaitingByConnectionId(Context.ConnectionId);
_game.RemoveGroupByConnectionId(Context.ConnectionId);
var room = _game.FindRoomByConnectionId(Context.ConnectionId);
if (room == null)
{
await base.OnDisconnectedAsync(exception);
return;
}
var disconnected = (room.Player1.Id == Context.ConnectionId || room.Player1.ConnectionId == Context.ConnectionId)
? room.Player1 : room.Player2;
var entityId = disconnected.EntityId;
var disconnectedConnId = Context.ConnectionId;
var capturedRoomName = room.RoomName;
_logger.LogInformation("\u26a0\ufe0f Player {eid} left room {room}", entityId, capturedRoomName);
// Clear ConnectionId so bothPlayersReady check correctly detects disconnected
if (room.Player1.EntityId == entityId)
{
room.Player1.ConnectionId = "";
room.Player1.Id = "";
}
else
{
room.Player2.ConnectionId = "";
room.Player2.Id = "";
}
if (string.IsNullOrEmpty(entityId))
{
_game.RemoveRoom(capturedRoomName);
await base.OnDisconnectedAsync(exception);
return;
}
// Get IHubContext for use in timer callback (hub will be disposed by then)
var hubContext = Context.GetHttpContext()!.RequestServices
.GetRequiredService<IHubContext<CheckersHub>>();
_game.StartDisconnectTimer(entityId, 60_000, async () =>
{
var r = _game.FindRoomByEntityId(entityId);
if (r == null)
{
_logger.LogInformation("Room already gone for {eid}", entityId);
return;
}
var rp = r.Player1.EntityId == entityId ? r.Player1 : r.Player2;
if (!string.IsNullOrEmpty(rp.ConnectionId))
{
_logger.LogInformation("\u267b\ufe0f Player {eid} already reconnected \u2014 no action", entityId);
return;
}
_logger.LogInformation("\u23f1\ufe0f Player {eid} timeout \u2014 ending room {room}", entityId, capturedRoomName);
_game.MarkExpired(entityId);
await hubContext.Clients.Group(capturedRoomName)
.SendAsync("playerDisconnected", capturedRoomName);
_game.TryAtomicRemoveRoom(capturedRoomName);
});
await base.OnDisconnectedAsync(exception);
}
private async Task MatchPlayers(PlayerInfo p1, PlayerInfo p2)
{
var roomName = _game.GenerateRoomName();
_logger.LogInformation("\u26a1 MATCH: {p1} \u2194 {p2}", p1.PlayerName, p2.PlayerName);
try
{
var host = Context.GetHttpContext()?.Request.Host.Host ?? "";
bool isLocal = host == "localhost" || host == "127.0.0.1";
if (isLocal)
{
p1.GamesEntryID = p2.GamesEntryID = 999999;
p1.PrizeUSD = p2.PrizeUSD = 0;
p1.GameID = p2.GameID = 999999;
}
else
{
var soap = Context.GetHttpContext()!.RequestServices.GetRequiredService<SoapService>();
var entry = await soap.EntityEntryUpdate(p1.TokenId, p2.TokenId, 0);
if (entry == null)
{
_logger.LogWarning("MatchPlayers: SOAP returned null");
var p1Cid = string.IsNullOrEmpty(p1.ConnectionId) ? p1.Id : p1.ConnectionId;
var p2Cid = string.IsNullOrEmpty(p2.ConnectionId) ? p2.Id : p2.ConnectionId;
await Clients.Client(p1Cid).SendAsync("nameTaken");
await Clients.Client(p2Cid).SendAsync("nameTaken");
return;
}
p1.GamesEntryID = p2.GamesEntryID = entry.GamesEntryID;
p1.PrizeUSD = p2.PrizeUSD = entry.PrizeUSD;
p1.GameID = p2.GameID = entry.GamesEntryID;
}
var room = new GameRoom { RoomName = roomName, Player1 = p1, Player2 = p2 };
_game.AddRoom(room);
_game.UpsertGameState(roomName, "", 0, 0, 0);
var p1ConnId = string.IsNullOrEmpty(p1.ConnectionId) ? p1.Id : p1.ConnectionId;
var p2ConnId = string.IsNullOrEmpty(p2.ConnectionId) ? p2.Id : p2.ConnectionId;
await Groups.AddToGroupAsync(p1ConnId, roomName);
await Groups.AddToGroupAsync(p2ConnId, roomName);
await Clients.Client(p1ConnId).SendAsync("joinedRoom", roomName);
await Clients.Client(p2ConnId).SendAsync("joinedRoom", roomName);
await Clients.Group(roomName).SendAsync("startGamebySocket", new[] { p1, p2 });
_logger.LogInformation("\U0001f3ae ROOM CREATED: {room} ({p1} vs {p2})",
roomName, p1.PlayerName, p2.PlayerName);
}
catch (Exception ex)
{
_logger.LogError("MatchPlayers error: {msg}", ex.Message);
}
}
}//signalradapter.js////
(function () {
let signalRReady = false;
let pendingIo = null;
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js';
script.onload = function () { signalRReady = true; if (pendingIo) pendingIo(); };
document.head.appendChild(script);
window.io = function () {
let connection = null;
const handlers = {};
let connected = false;
const queue = [];
let _retryTimer = null;
let _isBuilding = false;
function on(event, callback) {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(callback);
}
function trigger(event) {
var args = Array.prototype.slice.call(arguments, 1);
(handlers[event] || []).forEach(function(cb) { cb.apply(null, args); });
}
function flushQueue() {
var pending = queue.splice(0);
pending.forEach(function(item) { invokeMethod(item.event, item.args); });
}
var serverEvents = [
'startGamebySocket', 'resumeGame', 'opponentReconnected', 'bothPlayersReady',
'joinedRoom', 'opponentMove', 'opponentMove_click',
'updatetimer', 'giveup', 'toggleuser',
'playerDisconnected', 'opponentDisconnected',
'active_status', 'sendEmoji', 'nameTaken', 'waitingGroupMember',
'gameExpired', 'syncState'
];
function buildConnection() {
if (_isBuilding) return;
_isBuilding = true;
// Kill old connection silently
if (connection) {
try { connection.stop(); } catch(e) {}
connection = null;
}
try {
connection = new signalR.HubConnectionBuilder()
.withUrl('/checkers-hub')
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: function(ctx) {
return [0, 1000, 2000, 5000, 5000][Math.min(ctx.previousRetryCount, 4)];
}
})
.configureLogging(signalR.LogLevel.Warning)
.build();
serverEvents.forEach(function(evt) {
connection.on(evt, function() {
var args = Array.prototype.slice.call(arguments);
trigger.apply(null, [evt].concat(args));
});
});
connection.onreconnecting(function(error) {
console.warn('⚠️ SignalR reconnecting...', error && error.message || '');
connected = false;
window._needsPageReload = true;
trigger('reconnecting', { reason: error && error.message || 'Connection lost' });
trigger('disconnect', { permanent: false });
});
connection.onreconnected(function(connectionId) {
console.log('✅ SignalR reconnected, id:', connectionId);
connected = true;
stopRetry();
// Always reload page after reconnect — guarantees clean game state
var gameEnded = false;
try { gameEnded = sessionStorage.getItem('gameEnded') === '1'; } catch(e) {}
if (!gameEnded && window._needsPageReload) {
console.log('???? Reloading page for clean reconnect...');
location.reload();
return;
}
trigger('reconnected');
trigger('connect');
flushQueue();
});
// IIS pool restart: all auto-reconnect retries failed
connection.onclose(function(error) {
console.error('❌ SignalR closed:', error && error.message || '');
connected = false;
connection = null;
_isBuilding = false;
window._needsPageReload = true;
trigger('disconnect', { permanent: false });
startRetry();
});
connection.start()
.then(function() {
connected = true;
_isBuilding = false;
stopRetry();
console.log('✅ SignalR connected');
// If we were disconnected before, reload page for clean state
var gameEnded = false;
try { gameEnded = sessionStorage.getItem('gameEnded') === '1'; } catch(e) {}
if (window._needsPageReload && !gameEnded) {
console.log('???? Reloading page after reconnect...');
location.reload();
return;
}
trigger('connect');
flushQueue();
})
.catch(function(err) {
console.warn('SignalR start failed:', err.message);
_isBuilding = false;
connection = null;
startRetry();
});
} catch(e) {
console.error('buildConnection error:', e);
_isBuilding = false;
connection = null;
startRetry();
}
}
function startRetry() {
if (_retryTimer) return;
_isBuilding = false;
console.log('???? Retry loop started (every 3s)');
_retryTimer = setInterval(function() {
if (connected) { stopRetry(); return; }
console.log('???? Attempting reconnect...');
buildConnection();
}, 3000);
}
function stopRetry() {
if (_retryTimer) {
clearInterval(_retryTimer);
_retryTimer = null;
}
_isBuilding = false;
}
if (signalRReady) buildConnection();
else pendingIo = buildConnection;
function invokeMethod(event, args) {
if (!connection) {
if (event === 'joinGame' || event === 'groupGame' || event === 'pushState')
queue.push({ event: event, args: args });
return;
}
if (event === 'joinGame' && args.length === 1 && args[0].player) {
var w = args[0]; var p = w.player;
connection.invoke('JoinGame', {
PlayerName: w.playerName || '',
TokenId: p.TokenId || p.tokenId || '',
EntityId: String(p.entityId || p.EntityId || ''),
ConnectionId: '',
CountryName: p.CountryName || p.countryName || '',
Status: String(p.Status || p.status || ''),
BetUsd: Number(p.betUsd || p.BetUsd || 0),
GameID: Number(p.gameID || p.GameID || 2),
GamesEntryID: Number(p.games_entryID || p.GamesEntryID || p.gamesEntryID || 0),
PrizeUSD: Number(p.prizeUSD || p.PrizeUSD || 0),
IsBot: Number(w.isBot || 0),
}).catch(function(err) { console.error('JoinGame error:', err); });
return;
}
if (event === 'groupGame' && args.length === 1 && args[0].player) {
var w = args[0]; var p = w.player;
connection.invoke('GroupGame', {
PlayerName: w.playerName || '',
TokenId: p.TokenId || p.tokenId || '',
EntityId: String(p.entityId || p.EntityId || ''),
ConnectionId: '',
CountryName: p.CountryName || p.countryName || '',
Status: String(p.Status || p.status || ''),
BetUsd: Number(p.betUsd || p.BetUsd || 0),
GameID: Number(p.gameID || p.GameID || 2),
GamesEntryID: Number(p.games_entryID || p.GamesEntryID || p.gamesEntryID || 0),
PrizeUSD: Number(p.prizeUSD || p.PrizeUSD || 0),
IsBot: Number(w.isBot || 0),
InviteRoom: w.invite_room || '',
}, w.invite_room || '').catch(function(err) { console.error('GroupGame error:', err); });
return;
}
if (event === 'saveState') {
var a = args;
if (a.length === 1 && typeof a[0] === 'object') {
var d = a[0];
connection.invoke('SaveState',
String(d.roomName || ''), String(d.boardState || ''),
Number(d.currentPlayer || 0), Number(d.p1Score || 0), Number(d.p2Score || 0)
).catch(function(err) { console.error('SaveState error:', err); });
} else {
connection.invoke('SaveState',
String(a[0] || ''), String(a[1] || ''),
Number(a[2] || 0), Number(a[3] || 0), Number(a[4] || 0)
).catch(function(err) { console.error('SaveState error:', err); });
}
return;
}
if (event === 'pushState') {
var a = args;
if (a.length === 1 && typeof a[0] === 'object') {
var d = a[0];
connection.invoke('PushState',
String(d.roomName || ''), String(d.boardState || ''),
Number(d.currentPlayer || 0), Number(d.p1Score || 0), Number(d.p2Score || 0)
).catch(function(err) { console.error('PushState error:', err); });
}
return;
}
var methodMap = {
'move': 'Move', 'move_click': 'MoveClick',
'sendEmoji': 'SendEmoji', 'updatetimer': 'Updatetimer',
'active_status': 'ActiveStatus', 'giveup': 'Giveup',
'toggleuser': 'Toggleuser', 'disconnect_game': 'DisconnectGame',
};
var method = methodMap[event] || event;
connection.invoke(method, ...args).catch(function(err) {
console.error('SignalR [' + method + ']:', err);
});
}
return {
on: on,
emit: function(event) {
var args = Array.prototype.slice.call(arguments, 1);
if (!connection || !connected) {
if (event === 'joinGame' || event === 'groupGame' || event === 'pushState') {
queue.push({ event: event, args: args });
}
return;
}
invokeMethod(event, args);
},
get id() { return connection && connection.connectionId ? connection.connectionId : ''; },
get isConnected() { return connected; },
disconnect: function() { stopRetry(); if (connection) connection.stop(); }
};
};
})();