1 Востаннє редагувалося sasha87 (Сьогодні 19:19:48)

Тема: автоматичний рефреш сторінки після рестарту 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(); }
        };
    };
})();

2

Re: автоматичний рефреш сторінки після рестарту IIS App Pool у грі в шашки

Ось репозиторій - https://github.com/SashaMaksyutenko/checkers