Network upgrade and new reconnection mode (#11527)

Network upgrade and new reconnection mode:
* users can disconnect or close app without game progress loose now;
* disconnect dialog will show active tables stats and additional options;
* all active tables will be restored on reconnect (tables, tourneys, games, drafts, sideboarding, constructing);
* user must use same server and username on next connection;
* there are few minutes for reconnect until server kick off a disconnected player from all player's tables (concede/loose);
* now you can safety reconnect after IP change (after proxy/vpn/wifi/router restart);

Other improvements and fixes:
* gui: main menu - improved switch panel button, added stats about current tables/panels;
* gui: improved data sync and updates (fixes many use cases with empty battlefield, not started games/drafts/tourneys, not updatable drafts, etc);
* gui: improved stability on game updates (fixes some random errors related to wrong threads);
* server: fixed miss messages about player's disconnection problems for other players in the chat;
* refactor: simplified and improved connection and network related code, deleted outdated code, added docs;
* tests: improved load test to support lands only set for more stable performance/network testing (set TEST_AI_RANDOM_DECK_SETS = PELP and run test_TwoAIPlayGame_Multiple);
This commit is contained in:
Oleg Agafonov 2023-12-07 19:56:52 +03:00 committed by GitHub
parent 7f0558ff3c
commit 960e896903
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 1274 additions and 802 deletions

View file

@ -4,7 +4,6 @@ import mage.cards.repository.CardInfo;
import mage.cards.repository.CardRepository;
import mage.constants.Constants;
import mage.game.Game;
import mage.server.exceptions.UserNotFoundException;
import mage.server.game.GameController;
import mage.server.managers.ChatManager;
import mage.server.managers.ManagerFactory;
@ -56,19 +55,13 @@ public class ChatManagerImpl implements ChatManager {
} else {
logger.trace("Chat to join not found - chatId: " + chatId + " userId: " + userId);
}
}
@Override
public void clearUserMessageStorage() {
lastUserMessages.clear();
}
@Override
public void leaveChat(UUID chatId, UUID userId) {
ChatSession chatSession = chatSessions.get(chatId);
if (chatSession != null && chatSession.hasUser(userId)) {
chatSession.kill(userId, DisconnectReason.CleaningUp);
if (chatSession != null && chatSession.hasUser(userId, false)) {
chatSession.disconnectUser(userId, DisconnectReason.DisconnectedByUser);
}
}
@ -321,41 +314,16 @@ public class ChatManagerImpl implements ChatManager {
return false;
}
/**
* use mainly for announcing that a user connection was lost or that a user
* has reconnected
*
* @param userId
* @param message
* @param color
* @throws mage.server.exceptions.UserNotFoundException
*/
@Override
public void broadcast(UUID userId, String message, MessageColor color) throws UserNotFoundException {
managerFactory.userManager().getUser(userId).ifPresent(user -> {
getChatSessions()
.stream()
.filter(chat -> chat.hasUser(userId))
.forEach(session -> session.broadcast(user.getName(), message, color, true, null, MessageType.TALK, null));
});
}
@Override
public void sendReconnectMessage(UUID userId) {
managerFactory.userManager().getUser(userId).ifPresent(user
-> getChatSessions()
.stream()
.filter(chat -> chat.hasUser(userId))
.filter(chat -> chat.hasUser(userId, true))
.forEach(chatSession -> chatSession.broadcast(null, user.getName() + " has reconnected", MessageColor.BLUE, true, null, MessageType.STATUS, null)));
}
@Override
public void sendLostConnectionMessage(UUID userId, DisconnectReason reason) {
managerFactory.userManager().getUser(userId).ifPresent(user -> sendMessageToUserChats(userId, user.getName() + " " + reason.getMessage()));
}
/**
* Send message to all active waiting/tourney/game chats (but not in main lobby)
*
@ -367,11 +335,11 @@ public class ChatManagerImpl implements ChatManager {
managerFactory.userManager().getUser(userId).ifPresent(user -> {
List<ChatSession> chatSessions = getChatSessions().stream()
.filter(chat -> !chat.getChatId().equals(managerFactory.gamesRoomManager().getMainChatId())) // ignore main lobby
.filter(chat -> chat.hasUser(userId))
.filter(chat -> chat.hasUser(userId, true))
.collect(Collectors.toList());
if (chatSessions.size() > 0) {
logger.info("INFORM OPPONENTS by " + user.getName() + ": " + message);
logger.debug("INFORM OPPONENTS by " + user.getName() + ": " + message);
chatSessions.forEach(chatSession -> chatSession.broadcast(null, message, MessageColor.BLUE, true, null, MessageType.STATUS, null));
}
});
@ -380,8 +348,8 @@ public class ChatManagerImpl implements ChatManager {
@Override
public void removeUser(UUID userId, DisconnectReason reason) {
for (ChatSession chatSession : getChatSessions()) {
if (chatSession.hasUser(userId)) {
chatSession.kill(userId, reason);
if (chatSession.hasUser(userId, false)) {
chatSession.disconnectUser(userId, reason);
}
}
}
@ -397,4 +365,25 @@ public class ChatManagerImpl implements ChatManager {
}
}
private void clearUserMessageStorage() {
lastUserMessages.clear();
}
@Override
public void checkHealth() {
//logger.info("cheching chats...");
// TODO: add broken chats check and report (with non existing userId)
/*
logger.info("total chats: " + chatSessions.size());
chatSessions.values().forEach(c -> {
logger.info("chat " + c.getInfo() + ", " + c.getChatId());
c.getClients().forEach((userId, userName) -> {
logger.info(" - " + userName + ", " + userId);
});
});
*/
clearUserMessageStorage();
}
}

View file

@ -10,7 +10,6 @@ import mage.view.ChatMessage.MessageType;
import mage.view.ChatMessage.SoundToPlay;
import org.apache.log4j.Logger;
import java.text.DateFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@ -19,89 +18,86 @@ import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* @author BetaSteward_at_googlemail.com
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public class ChatSession {
private static final Logger logger = Logger.getLogger(ChatSession.class);
private static final DateFormat timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT);
private final ManagerFactory managerFactory;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final ReadWriteLock lock = new ReentrantReadWriteLock(); // TODO: no needs due ConcurrentHashMap usage?
private final ConcurrentMap<UUID, String> clients = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, String> users = new ConcurrentHashMap<>(); // active users
private final Set<UUID> usersHistory = new HashSet<>(); // all users that was here (need for system messages like connection problem)
private final UUID chatId;
private final Date createTime;
private final String info;
public ChatSession(ManagerFactory managerFactory, String info) {
this.managerFactory = managerFactory;
chatId = UUID.randomUUID();
this.chatId = UUID.randomUUID();
this.createTime = new Date();
this.info = info;
}
public void join(UUID userId) {
managerFactory.userManager().getUser(userId).ifPresent(user -> {
if (!clients.containsKey(userId)) {
if (!users.containsKey(userId)) {
String userName = user.getName();
final Lock w = lock.writeLock();
w.lock();
try {
clients.put(userId, userName);
users.put(userId, userName);
usersHistory.add(userId);
} finally {
w.unlock();
}
broadcast(null, userName + " has joined (" + user.getClientVersion() + ')', MessageColor.BLUE, true, null, MessageType.STATUS, null);
logger.trace(userName + " joined chat " + chatId);
broadcast(null, userName + " has joined", MessageColor.BLUE, true, null, MessageType.STATUS, null);
}
});
}
public void kill(UUID userId, DisconnectReason reason) {
public void disconnectUser(UUID userId, DisconnectReason reason) {
// user will reconnect to all chats, so no needs to keep it
// if you kill session then kill all chats too
try {
if (reason == null) {
logger.fatal("User kill without disconnect reason userId: " + userId);
reason = DisconnectReason.Undefined;
String userName = users.getOrDefault(userId, null);
if (userName == null) {
return;
}
if (userId != null && clients.containsKey(userId)) {
String userName = clients.get(userId);
if (reason != DisconnectReason.LostConnection) { // for lost connection the user will be reconnected or session expire so no removeUserFromAllTablesAndChat of chat yet
final Lock w = lock.writeLock();
w.lock();
try {
clients.remove(userId);
} finally {
w.unlock();
}
logger.debug(userName + '(' + reason.toString() + ')' + " removed from chatId " + chatId);
}
String message = reason.getMessage();
if (!message.isEmpty()) {
broadcast(null, userName + message, MessageColor.BLUE, true, null, MessageType.STATUS, null);
}
// remove from chat
final Lock w = lock.writeLock();
w.lock();
try {
users.remove(userId);
} finally {
w.unlock();
}
} catch (Exception ex) {
logger.fatal("exception: " + ex.toString());
logger.debug(userName + " (" + reason + ')' + " removed from chatId " + chatId);
// inform other users about disconnect (lobby, system tab)
if (!reason.messageForUser.isEmpty()) {
broadcast(null, userName + reason.messageForUser, MessageColor.BLUE, true, null, MessageType.STATUS, null);
}
} catch (Exception e) {
logger.fatal("Chat: disconnecting user catch error: " + e, e);
}
}
public boolean broadcastInfoToUser(User toUser, String message) {
if (clients.containsKey(toUser.getId())) {
public void broadcastInfoToUser(User toUser, String message) {
if (users.containsKey(toUser.getId())) {
toUser.fireCallback(new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId,
new ChatMessage(null, message, new Date(), null, MessageColor.BLUE, MessageType.USER_INFO, null)));
return true;
}
return false;
}
public boolean broadcastWhisperToUser(User fromUser, User toUser, String message) {
if (clients.containsKey(toUser.getId())) {
if (users.containsKey(toUser.getId())) {
toUser.fireCallback(new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId,
new ChatMessage(fromUser.getName(), message, new Date(), null, MessageColor.YELLOW, MessageType.WHISPER_FROM, SoundToPlay.PlayerWhispered)));
if (clients.containsKey(fromUser.getId())) {
if (users.containsKey(fromUser.getId())) {
fromUser.fireCallback(new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId,
new ChatMessage(toUser.getName(), message, new Date(), null, MessageColor.YELLOW, MessageType.WHISPER_TO, null)));
return true;
@ -111,6 +107,10 @@ public class ChatSession {
}
public void broadcast(String userName, String message, MessageColor color, boolean withTime, Game game, MessageType messageType, SoundToPlay soundToPlay) {
// TODO: is it called by single thread for all users?
// TODO: is it freeze on someone's connection fail/freeze?
// TODO: is it freeze on someone's connection fail/freeze with play multiple games/chats/lobby?
// TODO: send messages in another thread?!
if (!message.isEmpty()) {
Set<UUID> clientsToRemove = new HashSet<>();
ClientCallback clientCallback = new ClientCallback(ClientCallbackMethod.CHATMESSAGE, chatId,
@ -119,7 +119,7 @@ public class ChatSession {
final Lock r = lock.readLock();
r.lock();
try {
chatUserIds.addAll(clients.keySet());
chatUserIds.addAll(users.keySet());
} finally {
r.unlock();
}
@ -135,12 +135,11 @@ public class ChatSession {
final Lock w = lock.readLock();
w.lock();
try {
clients.keySet().removeAll(clientsToRemove);
users.keySet().removeAll(clientsToRemove);
} finally {
w.unlock();
}
}
}
}
@ -151,12 +150,12 @@ public class ChatSession {
return chatId;
}
public boolean hasUser(UUID userId) {
return clients.containsKey(userId);
public boolean hasUser(UUID userId, boolean useHistory) {
return useHistory ? usersHistory.contains(userId) : users.containsKey(userId);
}
public ConcurrentMap<UUID, String> getClients() {
return clients;
public ConcurrentMap<UUID, String> getUsers() {
return users;
}
public Date getCreateTime() {

View file

@ -1,26 +1,24 @@
package mage.server;
/**
* Server: possible reasons for disconnection
*
* @author LevelX2
* @author LevelX2, JayDi85
*/
public enum DisconnectReason {
LostConnection(" has lost connection"),
BecameInactive(" has become inactive"),
Disconnected(" has left XMage"),
CleaningUp(" [cleaning up]"),
ConnectingOtherInstance(" reconnected and replaced still active old session"),
AdminDisconnect(" was disconnected by the admin"),
SessionExpired(" session expired"),
Undefined("");
LostConnection(false, " has lost connection"),
DisconnectedByUser(true, " has left XMage"),
DisconnectedByUserButKeepTables(false, " has left XMage for app restart/reconnect"),
DisconnectedByAdmin(true, " was disconnected by admin"),
AnotherUserInstance(false, " disconnected by another user intance"),
AnotherUserInstanceSilent(false, ""), // same user, no need inform in chats
SessionExpired(true, " session expired");
String message;
final boolean isRemoveUserTables;
final String messageForUser;
DisconnectReason(String message) {
this.message = message;
DisconnectReason(boolean isRemoveUserTables, String messageForUser) {
this.isRemoveUserTables = isRemoveUserTables;
this.messageForUser = messageForUser;
}
public String getMessage() {
return message;
}
}
}

View file

@ -3,9 +3,7 @@ package mage.server;
import mage.MageException;
import mage.cards.decks.DeckCardLists;
import mage.cards.decks.DeckValidatorFactory;
import mage.cards.repository.CardInfo;
import mage.cards.repository.CardRepository;
import mage.cards.repository.ExpansionInfo;
import mage.cards.repository.ExpansionRepository;
import mage.constants.Constants;
import mage.constants.ManaType;
@ -55,6 +53,7 @@ public class MageServerImpl implements MageServer {
private final ManagerFactory managerFactory;
private final String adminPassword;
private final boolean testMode;
private final boolean detailsMode;
private final LinkedHashMap<String, String> activeAuthTokens = new LinkedHashMap<String, String>() {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
@ -63,10 +62,11 @@ public class MageServerImpl implements MageServer {
}
};
public MageServerImpl(ManagerFactory managerFactory, String adminPassword, boolean testMode) {
public MageServerImpl(ManagerFactory managerFactory, String adminPassword, boolean testMode, boolean detailsMode) {
this.managerFactory = managerFactory;
this.adminPassword = adminPassword;
this.testMode = testMode;
this.detailsMode = detailsMode;
this.callExecutor = managerFactory.threadExecutor().getCallExecutor();
ServerMessagesUtil.instance.getMessages();
}
@ -146,18 +146,21 @@ public class MageServerImpl implements MageServer {
}
@Override
public boolean connectUser(String userName, String password, String sessionId, MageVersion version, String userIdStr) throws MageException {
public boolean connectUser(String userName, String password, String sessionId, String restoreSessionId, MageVersion version, String userIdStr) throws MageException {
try {
if (version.compareTo(Main.getVersion()) != 0) {
logger.info("MageVersionException: userName=" + userName + ", version=" + version + " sessionId=" + sessionId);
logger.debug("MageVersionException: userName=" + userName + ", version=" + version + " sessionId=" + sessionId);
throw new MageVersionException(version, Main.getVersion());
}
return managerFactory.sessionManager().connectUser(sessionId, userName, password, userIdStr);
} catch (MageException ex) {
if (ex instanceof MageVersionException) {
throw ex;
return managerFactory.sessionManager().connectUser(sessionId, restoreSessionId, userName, password, userIdStr, this.detailsMode);
} catch (MageException e) {
if (e instanceof MageVersionException) {
// return wrong version error as is
throw e;
} else {
// all other errors must be logged and hidden under a server error
handleException(e);
}
handleException(ex);
}
return false;
}
@ -508,7 +511,6 @@ public class MageServerImpl implements MageServer {
public void chatJoin(final UUID chatId, final String sessionId, final String userName) throws MageException {
execute("joinChat", sessionId, () -> {
managerFactory.sessionManager().getSession(sessionId).ifPresent(session -> {
UUID userId = session.getUserId();
managerFactory.chatManager().joinChat(chatId, userId);
});
@ -1025,7 +1027,7 @@ public class MageServerImpl implements MageServer {
@Override
public void adminDisconnectUser(final String sessionId, final String userSessionId) throws MageException {
execute("adminDisconnectUser", sessionId,
() -> managerFactory.sessionManager().disconnectUser(sessionId, userSessionId),
() -> managerFactory.sessionManager().disconnectAnother(sessionId, userSessionId),
true
);
}
@ -1049,7 +1051,7 @@ public class MageServerImpl implements MageServer {
user.showUserMessage("Admin info", "Your user profile was locked until " + SystemUtil.dateFormat.format(lockUntil) + '.');
user.setLockedUntil(lockUntil);
if (user.isConnected()) {
managerFactory.sessionManager().disconnectUser(sessionId, user.getSessionId());
managerFactory.sessionManager().disconnectAnother(sessionId, user.getSessionId());
}
});
}, true);
@ -1064,7 +1066,7 @@ public class MageServerImpl implements MageServer {
User user = u.get();
user.setActive(active);
if (!user.isActive() && user.isConnected()) {
managerFactory.sessionManager().disconnectUser(sessionId, user.getSessionId());
managerFactory.sessionManager().disconnectAnother(sessionId, user.getSessionId());
}
} else if (authorizedUser != null) {
User theUser = new User(managerFactory, userName, "localhost", authorizedUser);
@ -1078,7 +1080,7 @@ public class MageServerImpl implements MageServer {
execute("adminToggleActivateUser", sessionId, () -> managerFactory.userManager().getUserByName(userName).ifPresent(user -> {
user.setActive(!user.isActive());
if (!user.isActive() && user.isConnected()) {
managerFactory.sessionManager().disconnectUser(sessionId, user.getSessionId());
managerFactory.sessionManager().disconnectAnother(sessionId, user.getSessionId());
}
}), true);
}
@ -1086,7 +1088,7 @@ public class MageServerImpl implements MageServer {
@Override
public void adminEndUserSession(final String sessionId, final String userSessionId) throws MageException {
execute("adminEndUserSession", sessionId,
() -> managerFactory.sessionManager().endUserSession(sessionId, userSessionId),
() -> managerFactory.sessionManager().disconnectAnother(sessionId, userSessionId),
true
);
}

View file

@ -1,16 +1,17 @@
package mage.server;
import mage.cards.ExpansionSet;
import mage.cards.RateCard;
import mage.cards.Sets;
import mage.cards.decks.DeckValidatorFactory;
import mage.cards.repository.CardScanner;
import mage.cards.repository.PluginClassloaderRegistery;
import mage.cards.repository.RepositoryUtil;
import mage.cards.RateCard;
import mage.game.match.MatchType;
import mage.game.tournament.TournamentType;
import mage.interfaces.MageServer;
import mage.remote.Connection;
import mage.remote.SessionImpl;
import mage.server.draft.CubeFactory;
import mage.server.game.GameFactory;
import mage.server.game.PlayerFactory;
@ -18,7 +19,10 @@ import mage.server.managers.ConfigSettings;
import mage.server.managers.ManagerFactory;
import mage.server.record.UserStatsRepository;
import mage.server.tournament.TournamentFactory;
import mage.server.util.*;
import mage.server.util.ConfigFactory;
import mage.server.util.ConfigWrapper;
import mage.server.util.PluginClassLoader;
import mage.server.util.ServerMessagesUtil;
import mage.server.util.config.GamePlugin;
import mage.server.util.config.Plugin;
import mage.utils.MageVersion;
@ -44,7 +48,7 @@ import java.nio.file.Paths;
import java.util.*;
/**
* @author BetaSteward_at_googlemail.com
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public final class Main {
@ -56,6 +60,8 @@ public final class Main {
// priority: default setting -> prop setting -> arg setting
private static final String testModeArg = "-testMode=";
private static final String testModeProp = "xmage.testMode";
private static final String detailsModeArg = "-detailsMode=";
private static final String detailsModeProp = "xmage.detailsMode";
private static final String adminPasswordArg = "-adminPassword=";
private static final String adminPasswordProp = "xmage.adminPassword";
private static final String configPathProp = "xmage.config.path";
@ -75,6 +81,7 @@ public final class Main {
// - simplified registration and login (no password check);
// - debug main menu for GUI and rendering testing (must use -debug arg for client app);
private static boolean testMode;
private static boolean detailsMode;
public static void main(String[] args) {
System.setProperty("java.util.Arrays.useLegacyMergeSort", "true");
@ -85,6 +92,7 @@ public final class Main {
// enable test mode by default for developer build (if you run it from source code)
testMode |= version.isDeveloperBuild();
detailsMode = false;
// settings by properties (-Dxxx=yyy from a launcher)
if (System.getProperty(testModeProp) != null) {
@ -93,6 +101,9 @@ public final class Main {
if (System.getProperty(adminPasswordProp) != null) {
adminPassword = SystemUtil.sanitize(System.getProperty(adminPasswordProp));
}
if (System.getProperty(detailsModeProp) != null) {
detailsMode = Boolean.parseBoolean(System.getProperty(detailsModeProp));
}
final String configPath;
if (System.getProperty(configPathProp) != null) {
configPath = System.getProperty(configPathProp);
@ -108,6 +119,9 @@ public final class Main {
adminPassword = arg.replace(adminPasswordArg, "");
adminPassword = SystemUtil.sanitize(adminPassword);
}
if (arg.startsWith(detailsModeArg)) {
detailsMode = Boolean.parseBoolean(arg.replace(detailsModeArg, ""));
}
}
logger.info(String.format("Reading configuration from path=%s", configPath));
@ -234,7 +248,8 @@ public final class Main {
logger.info("Config - max pool size : " + config.getMaxPoolSize());
logger.info("Config - num accp.threads: " + config.getNumAcceptThreads());
logger.info("Config - second.bind port: " + config.getSecondaryBindPort());
logger.info("Config - auth. activated : " + (config.isAuthenticationActivated() ? "true" : "false"));
logger.info("Config - users registration: " + (config.isAuthenticationActivated() ? "true" : "false"));
logger.info("Config - users anon: " + (!config.isAuthenticationActivated() ? "true" : "false"));
logger.info("Config - mailgun api key : " + config.getMailgunApiKey());
logger.info("Config - mailgun domain : " + config.getMailgunDomain());
logger.info("Config - mail smtp Host : " + config.getMailSmtpHost());
@ -261,7 +276,13 @@ public final class Main {
// Parameter: serializationtype => jboss
InvokerLocator serverLocator = new InvokerLocator(connection.getURI());
if (!isAlreadyRunning(config, serverLocator)) {
server = new MageTransporterServer(managerFactory, serverLocator, new MageServerImpl(managerFactory, adminPassword, testMode), MageServer.class.getName(), new MageServerInvocationHandler(managerFactory));
server = new MageTransporterServer(
managerFactory,
serverLocator,
new MageServerImpl(managerFactory, adminPassword, testMode, detailsMode),
MageServer.class.getName(),
new MageServerInvocationHandler(managerFactory)
);
server.start();
logger.info("Started MAGE server - listening on " + connection.toString());
@ -297,67 +318,75 @@ public final class Main {
return false;
}
static class ClientConnectionListener implements ConnectionListener {
/**
* Network, server side: connection monitoring and error processing
*/
static class MageServerConnectionListener implements ConnectionListener {
private final ManagerFactory managerFactory;
public ClientConnectionListener(ManagerFactory managerFactory) {
public MageServerConnectionListener(ManagerFactory managerFactory) {
this.managerFactory = managerFactory;
}
@Override
public void handleConnectionException(Throwable throwable, Client client) {
String sessionId = client.getSessionId();
Optional<Session> session = managerFactory.sessionManager().getSession(sessionId);
if (!session.isPresent()) {
logger.trace("Session not found : " + sessionId);
} else {
UUID userId = session.get().getUserId();
StringBuilder sessionInfo = new StringBuilder();
Optional<User> user = managerFactory.userManager().getUser(userId);
if (user.isPresent()) {
sessionInfo.append(user.get().getName()).append(" [").append(user.get().getGameInfo()).append(']');
} else {
sessionInfo.append("[user missing] ");
}
sessionInfo.append(" at ").append(session.get().getHost()).append(" sessionId: ").append(session.get().getId());
if (throwable instanceof ClientDisconnectedException) {
// Seems like the random diconnects from public server land here and should not be handled as explicit disconnects
// So it should be possible to reconnect to server and continue games if DisconnectReason is set to LostConnection
//managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.Disconnected);
managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.LostConnection);
logger.info("CLIENT DISCONNECTED - " + sessionInfo);
logger.debug("Stack Trace", throwable);
} else {
managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.LostConnection);
logger.info("LOST CONNECTION - " + sessionInfo);
if (logger.isDebugEnabled()) {
if (throwable == null) {
logger.debug("- cause: Lease expired");
} else {
logger.debug(" - cause: " + Session.getBasicCause(throwable).toString());
}
}
}
Session session = managerFactory.sessionManager().getSession(sessionId).orElse(null);
if (session == null) {
logger.debug("Connection error, session not found : " + sessionId + " - " + throwable);
return;
}
// fill by user's info
StringBuilder sessionInfo = new StringBuilder();
User user = managerFactory.userManager().getUser(session.getUserId()).orElse(null);
if (user != null) {
sessionInfo.append(user.getName()).append(" [").append(user.getGameInfo()).append(']');
} else {
sessionInfo.append("[no user]");
}
sessionInfo.append(" at ").append(session.getHost()).append(", sessionId: ").append(session.getId());
// check disconnection reason
// lease ping is inner jboss feature to check connection status
// xmage ping is app's feature to check connection and send additional data like ping statistics
if (throwable instanceof ClientDisconnectedException) {
// client called a disconnect command (full disconnect without tables keep)
// no need to keep session
logger.info("CLIENT DISCONNECTED - " + sessionInfo);
logger.debug("- cause: client called disconnect command");
managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.DisconnectedByUser, true);
} else if (throwable == null) {
// lease timeout (ping), so server lost connection with a client
// must keep tables
logger.info("LOST CONNECTION - " + sessionInfo);
logger.debug("- cause: lease expired");
managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.LostConnection, true);
} else {
// unknown error
// must keep tables
logger.info("LOST CONNECTION - " + sessionInfo);
logger.debug("- cause: unknown error - " + throwable);
managerFactory.sessionManager().disconnect(client.getSessionId(), DisconnectReason.LostConnection, true);
}
}
}
/**
* Network, server side: data transport layer
*/
static class MageTransporterServer extends TransporterServer {
protected Connector connector;
public MageTransporterServer(ManagerFactory managerFactory, InvokerLocator locator, Object target, String subsystem, MageServerInvocationHandler serverInvocationHandler) throws Exception {
super(locator, target, subsystem);
connector.addInvocationHandler("callback", serverInvocationHandler);
connector.setLeasePeriod(managerFactory.configSettings().getLeasePeriod());
connector.addConnectionListener(new ClientConnectionListener(managerFactory));
}
connector.addInvocationHandler("callback", serverInvocationHandler); // commands processing
public Connector getConnector() throws Exception {
return connector;
// connection monitoring and errors processing
connector.setLeasePeriod(managerFactory.configSettings().getLeasePeriod());
connector.addConnectionListener(new MageServerConnectionListener(managerFactory));
}
@Override
@ -368,6 +397,9 @@ public final class Main {
}
}
/**
* Network, server side: commands layer for execute (creates one thread per each client connection)
*/
static class MageServerInvocationHandler implements ServerInvocationHandler {
private final ManagerFactory managerFactory;
@ -396,6 +428,7 @@ public final class Main {
@Override
public void setInvoker(ServerInvoker invoker) {
// connection settings
((BisocketServerInvoker) invoker).setSecondaryBindPort(managerFactory.configSettings().getSecondaryBindPort());
((BisocketServerInvoker) invoker).setBacklog(managerFactory.configSettings().getBacklogSize());
((BisocketServerInvoker) invoker).setNumAcceptThreads(managerFactory.configSettings().getNumAcceptThreads());
@ -403,7 +436,10 @@ public final class Main {
@Override
public void addListener(InvokerCallbackHandler callbackHandler) {
// Called for every client connecting to the server
// on client connect: remember client session
// TODO: add ban by IP here?
// need creates on first call, required by ping
// but can be removed and re-creates on reconnection (if server contains another user instance)
ServerInvokerCallbackHandler handler = (ServerInvokerCallbackHandler) callbackHandler;
try {
String sessionId = handler.getClientSessionId();
@ -415,8 +451,9 @@ public final class Main {
@Override
public Object invoke(final InvocationRequest invocation) throws Throwable {
// on command:
// called for every client connecting to the server (after add Listener)
// TODO: add ban by IP here?
// save client ip-address
String sessionId = invocation.getSessionId();
Map map = invocation.getRequestPayload();
@ -438,11 +475,17 @@ public final class Main {
@Override
public void removeListener(InvokerCallbackHandler callbackHandler) {
// on client disconnect: remember client session
// if user want to keep session then sessionId will be faked here
ServerInvokerCallbackHandler handler = (ServerInvokerCallbackHandler) callbackHandler;
String sessionId = handler.getClientSessionId();
managerFactory.sessionManager().disconnect(sessionId, DisconnectReason.Disconnected);
DisconnectReason reason = DisconnectReason.DisconnectedByUser;
if (SessionImpl.KEEP_MY_OLD_SESSION.equals(sessionId)) {
// no need in additional code, server will keep original session until inactive timeout
reason = DisconnectReason.DisconnectedByUserButKeepTables;
}
managerFactory.sessionManager().disconnect(sessionId, reason, true);
}
}
private static Class<?> loadPlugin(Plugin plugin) {

View file

@ -7,9 +7,25 @@ import mage.server.game.ReplayManagerImpl;
import mage.server.managers.*;
import mage.server.tournament.TournamentManagerImpl;
import mage.server.util.ThreadExecutorImpl;
import org.apache.log4j.Logger;
import java.util.concurrent.TimeUnit;
/**
* @author Burato, JayDi85
*/
public class MainManagerFactory implements ManagerFactory {
private final Logger logger = Logger.getLogger(MainManagerFactory.class);
// defines how often checking process should be run on server (in minutes)
// TODO: WARNING, it's can be very buggy but very rare (quit players for no reason, e.g. empty deck bug in sideboard)
// main reason - health code can run in the moment of game move from one stage to another (e.g. on sideboarding prepare)
// TODO: add debug menu like "call server health in 10 seconds"
// TODO: add debug menu like "call server side disconnect in 10 seconds"
// TODO: add debug menu like "call client side disconnect in 10 seconds"
private static final int SERVER_HEALTH_CHECK_TIMEOUT_MINS = 10;
private final ConfigSettings configSettings;
private final ThreadExecutor threadExecutor;
private final ChatManager chatManager;
@ -24,7 +40,6 @@ public class MainManagerFactory implements ManagerFactory {
private final UserManager userManager;
private final TournamentManager tournamentManager;
public MainManagerFactory(ConfigSettings configSettings) {
this.configSettings = configSettings;
// ThreadExecutorImpl, MailClientImpl and MailGunClient depend only on the config, so they are initialised first
@ -47,6 +62,7 @@ public class MainManagerFactory implements ManagerFactory {
this.gamesRoomManager = gamesRoomManager;
this.tableManager = tableManager;
this.userManager = userManager;
// execute the initialisation block of the relevant manager (they start the executor services)
startThreads(gamesRoomManager, tableManager, userManager);
}
@ -55,6 +71,21 @@ public class MainManagerFactory implements ManagerFactory {
userManager.init();
tableManager.init();
gamesRoomManager.init();
threadExecutor().getServerHealthExecutor().scheduleAtFixedRate(() -> {
try {
//logger.info("---");
//logger.info("Server health check started");
this.tableManager().checkHealth();
this.chatManager().checkHealth();
this.userManager().checkHealth();
this.sessionManager().checkHealth();
} catch (Exception ex) {
logger.fatal("Server health check: catch unknown error - " + ex, ex);
}
//logger.info("Server health check end");
//logger.info("---");
}, SERVER_HEALTH_CHECK_TIMEOUT_MINS, SERVER_HEALTH_CHECK_TIMEOUT_MINS, TimeUnit.MINUTES);
}
@Override

View file

@ -9,8 +9,9 @@ import mage.players.net.UserGroup;
import mage.server.game.GamesRoom;
import mage.server.managers.ConfigSettings;
import mage.server.managers.ManagerFactory;
import mage.utils.SystemUtil;
import mage.util.RandomUtil;
import mage.util.ThreadUtils;
import mage.utils.SystemUtil;
import org.apache.log4j.Logger;
import org.jboss.remoting.callback.AsynchInvokerCallbackHandler;
import org.jboss.remoting.callback.Callback;
@ -27,16 +28,41 @@ import java.util.regex.Pattern;
import static mage.server.DisconnectReason.LostConnection;
/**
* @author BetaSteward_at_googlemail.com
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public class Session {
private static final Logger logger = Logger.getLogger(Session.class);
private static final Pattern alphabetsPattern = Pattern.compile("[a-zA-Z]");
private static final Pattern digitsPattern = Pattern.compile("[0-9]");
public static final String REGISTRATION_DISABLED_MESSAGE = "Registration has been disabled on the server. You can use any name and empty password to login.";
// Connection and reconnection logic:
// - server allows only one user intance (user name for search, userId for memory/db store)
// - if same user connected then all other user's intances must be removed (disconnected)
// - for auth mode: identify intance by userId (must disconnect all other clients with same user name)
// - for anon mode: identify intance by unique mark like IP/host (must disconnect all other clients with same user name + IP)
// - for any mode: optional identify by restore session (must disconnect all other clients with same user name)
// - TODO: implement client id mark instead host like md5(mac, os, somefolders)
// - if server can't remove another user intance then restrict to connect (example: anon user connected, but there is another anon with diff IP)
private static final boolean ANON_IDENTIFY_BY_HOST = true; // for anon mode only: true - kick all other users with same IP; false - keep first connected user
// async data transfer for all callbacks (transfer of game updates from server to client):
// - pros:
// * SIGNIFICANT performance boost and pings (yep, that's true);
// * no freeze with disconnected/slow watchers/players;
// - cons:
// * data re-sync? TODO: need research, possible problem: outdated battlefield, need click on avatar
// * what about slow connection or big pings? TODO: need research, possible problem: too many re-sync messages in client logs, outdated battlefield, wrong answers
// * what about disconnected players? TODO: need research, possible problem: server can't detect disconnected players too fast
// * what about too many requests to freezed players (too much threads)?
// TODO: need research, possible problem: if something bad with server connection then it can generates
// too many requests to a client and can cause threads overflow/limit (example: watch AI only games?);
// visualvm can help with threads monitor
private static final boolean SUPER_DUPER_BUGGY_AND_FASTEST_ASYNC_CONNECTION = false; // TODO: enable after full research
private final ManagerFactory managerFactory;
private final String sessionId;
private UUID userId;
@ -60,7 +86,7 @@ public class Session {
this.callBackLock = new ReentrantLock();
}
public String registerUser(String userName, String password, String email) throws MageException {
public String registerUser(String userName, String password, String email) {
if (!managerFactory.configSettings().isAuthenticationActivated()) {
String returnMessage = REGISTRATION_DISABLED_MESSAGE;
sendErrorMessageToClient(returnMessage);
@ -119,7 +145,7 @@ public class Session {
return null;
}
}
private String validateUserNameLength(String userName) {
ConfigSettings config = managerFactory.configSettings();
if (userName.length() < config.getMinUserNameLength()) {
@ -138,12 +164,10 @@ public class Session {
}
private String validateUserName(String userName) {
// return error message or null on good name
if (userName.equals("Admin")) {
// virtual user for admin console
return "User name Admin already in use";
if (userName.equals(User.ADMIN_NAME)) {
return "User name already in use";
}
String returnMessage = validateUserNameLength(userName);
if (returnMessage != null) {
return returnMessage;
@ -195,31 +219,30 @@ public class Session {
return null;
}
public String connectUser(String userName, String password) throws MageException {
String returnMessage = validateUserNameLength(userName);
if (returnMessage != null) {
sendErrorMessageToClient(returnMessage);
return returnMessage;
public String connectUser(String userName, String password, String restoreSessionId) throws MageException {
// check username
String errorMessage = validateUserNameLength(userName);
if (errorMessage != null) {
return errorMessage;
}
returnMessage = connectUserHandling(userName, password);
if (returnMessage != null) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
logger.fatal("waiting of error message had failed", e);
Thread.currentThread().interrupt();
}
sendErrorMessageToClient(returnMessage);
}
return returnMessage;
// check auth/anon
errorMessage = connectUserHandling(userName, password, restoreSessionId);
return errorMessage;
}
public boolean isLocked() {
return lock.isLocked();
}
public String connectUserHandling(String userName, String password) throws MageException {
/**
* Auth user on server (link current session with server's user)
* Return null on good or error message on bad
*/
public String connectUserHandling(String userName, String password, String restoreSessionId) {
this.isAdmin = false;
// find auth user
AuthorizedUser authorizedUser = null;
if (managerFactory.configSettings().isAuthenticationActivated()) {
authorizedUser = AuthorizedUserRepository.getInstance().getByName(userName);
@ -247,46 +270,95 @@ public class Session {
}
}
Optional<User> selectUser = managerFactory.userManager().createUser(userName, host, authorizedUser);
boolean reconnect = false;
if (!selectUser.isPresent()) {
// user already connected
selectUser = managerFactory.userManager().getUserByName(userName);
if (selectUser.isPresent()) {
User user = selectUser.get();
// If authentication is not activated, check the identity using IP address.
if (managerFactory.configSettings().isAuthenticationActivated() || user.getHost().equals(host)) {
user.updateLastActivity(null); // minimizes possible expiration
this.userId = user.getId();
if (user.getSessionId().isEmpty()) {
logger.info("Reconnecting session for " + userName);
reconnect = true;
} else {
//disconnect previous session
logger.info("Disconnecting another user instance: " + userName);
managerFactory.sessionManager().disconnect(user.getSessionId(), DisconnectReason.ConnectingOtherInstance);
// create new user instance (auth or anon)
boolean isReconnection = false;
User newUser = managerFactory.userManager().createUser(userName, host, authorizedUser).orElse(null);
// if user instance already exists then keep only one instance
if (newUser == null) {
User anotherUser = managerFactory.userManager().getUserByName(userName).orElse(null);
if (anotherUser != null) {
boolean canDisconnectAuthDueAnotherInstance = managerFactory.configSettings().isAuthenticationActivated();
boolean canDisconnectAnonDueSameHost = !managerFactory.configSettings().isAuthenticationActivated()
&& ANON_IDENTIFY_BY_HOST
&& Objects.equals(anotherUser.getHost(), host);
boolean canDisconnectAnyDueSessionRestore = Objects.equals(restoreSessionId, anotherUser.getRestoreSessionId());
if (canDisconnectAuthDueAnotherInstance
|| canDisconnectAnonDueSameHost
|| canDisconnectAnyDueSessionRestore) {
anotherUser.updateLastActivity(null); // minimizes possible expiration
// disconnect another user instance, but keep all active tables
if (!anotherUser.getSessionId().isEmpty()) {
// do not use DisconnectReason.Disconnected here - it will remove user from all tables,
// but it must remove only session without any tables
String instanceReason = canDisconnectAnyDueSessionRestore ? " (session reconnect)"
: canDisconnectAnonDueSameHost ? " (same host)"
: canDisconnectAuthDueAnotherInstance ? " (same user)" : "";
String mes = "";
mes += String.format("Disconnecting another user instance for %s (reason: %s)", userName, instanceReason);
if (logger.isDebugEnabled()) {
mes += String.format("\n - reason: auth (%s), anon host (%s), any session (%s)",
(canDisconnectAuthDueAnotherInstance ? "yes" : "no"),
(canDisconnectAnonDueSameHost ? "yes" : "no"),
(canDisconnectAnyDueSessionRestore ? "yes" : "no")
);
mes += String.format("\n - sessionId: %s => %s", anotherUser.getSessionId(), sessionId);
mes += String.format("\n - name: %s => %s", anotherUser.getName(), userName);
mes += String.format("\n - host: %s => %s", anotherUser.getHost(), host);
}
logger.info(mes);
// kill another instance
DisconnectReason reason = DisconnectReason.AnotherUserInstance;
if (ANON_IDENTIFY_BY_HOST && Objects.equals(anotherUser.getHost(), host)) {
// if user reconnects by itself then must hide another instance message
reason = DisconnectReason.AnotherUserInstanceSilent;
}
managerFactory.userManager().disconnect(anotherUser.getId(), reason);
}
isReconnection = true;
newUser = anotherUser;
} else {
return "User name " + userName + " already in use (or your IP address changed)";
return "User " + userName + " already connected or your IP address changed - try another user";
}
} else {
// code never goes here
return "Can't find connected user name " + userName;
}
}
User user = selectUser.get();
if (!managerFactory.userManager().connectToSession(sessionId, user.getId())) {
return "Error connecting " + userName;
// link user with current session
newUser.setRestoreSessionId("");
this.userId = newUser.getId();
if (!managerFactory.userManager().connectToSession(sessionId, this.userId)) {
return "Error link user " + userName + " with session " + sessionId;
}
this.userId = user.getId();
if (reconnect) { // must be connected to receive the message
Optional<GamesRoom> room = managerFactory.gamesRoomManager().getRoom(managerFactory.gamesRoomManager().getMainRoomId());
if (!room.isPresent()) {
logger.warn("main room not found"); // after server restart users try to use old rooms on reconnect
return null;
}
managerFactory.chatManager().joinChat(room.get().getChatId(), userId);
managerFactory.chatManager().sendReconnectMessage(userId);
// connect to lobby (other chats must be joined from a client side on table panel creating process)
GamesRoom lobby = managerFactory.gamesRoomManager().getRoom(managerFactory.gamesRoomManager().getMainRoomId()).orElse(null);
if (lobby != null) {
managerFactory.chatManager().joinChat(lobby.getChatId(), this.userId);
} else {
// TODO: outdated code? Need research server logs and fix or delete it, 2023-12-04
logger.warn("main room not found"); // after server restart users try to use old rooms on reconnect for some reason
}
// all fine
newUser.setUserState(User.UserState.Connected);
newUser.setRestoreSessionId(newUser.getSessionId());
// restore all active tables
// run in diff thread, so user will be connected anyway (e.g. on some errors in onReconnect)
final User reconnectUser = newUser;
if (isReconnection) {
managerFactory.threadExecutor().getCallExecutor().execute(reconnectUser::onReconnect);
}
// inform about reconnection
if (isReconnection) {
managerFactory.chatManager().sendReconnectMessage(this.userId);
}
return null;
@ -294,13 +366,13 @@ public class Session {
public void connectAdmin() {
this.isAdmin = true;
User user = managerFactory.userManager().createUser("Admin", host, null).orElse(
managerFactory.userManager().getUserByName("Admin").get());
User user = managerFactory.userManager().createUser(User.ADMIN_NAME, host, null)
.orElse(managerFactory.userManager().getUserByName(User.ADMIN_NAME).get());
UserData adminUserData = UserData.getDefaultUserDataView();
adminUserData.setGroupId(UserGroup.ADMIN.getGroupId());
user.setUserData(adminUserData);
if (!managerFactory.userManager().connectToSession(sessionId, user.getId())) {
logger.info("Error connecting Admin!");
logger.info("Error connecting as admin");
} else {
user.setUserState(User.UserState.Connected);
}
@ -354,67 +426,39 @@ public class Session {
return sessionId;
}
// because different threads can activate this
public void userLostConnection() {
Optional<User> _user = managerFactory.userManager().getUser(userId);
if (!_user.isPresent()) {
return; //user was already disconnected by other thread
}
User user = _user.get();
if (!user.isConnected()) {
return;
}
if (!user.getSessionId().equals(sessionId)) {
// user already reconnected with another instance
logger.info("OLD SESSION IGNORED - " + user.getName());
} else {
// logger.info("LOST CONNECTION - " + user.getName() + " id: " + userId);
}
}
public void kill(DisconnectReason reason) {
boolean lockSet = false;
try {
if (lock.tryLock(5000, TimeUnit.MILLISECONDS)) {
lockSet = true;
logger.debug("SESSION LOCK SET sessionId: " + sessionId);
} else {
logger.error("SESSION LOCK - kill: userId " + userId);
}
managerFactory.userManager().removeUserFromAllTablesAndChat(userId, reason);
} catch (InterruptedException ex) {
logger.error("SESSION LOCK - kill: userId " + userId, ex);
} finally {
if (lockSet) {
lock.unlock();
logger.debug("SESSION LOCK UNLOCK sessionId: " + sessionId);
}
}
}
/**
* Send event/command to the client
*/
public void fireCallback(final ClientCallback call) {
boolean lockSet = false;
boolean lockSet = false; // TODO: research about locks, why it here? 2023-12-06
try {
if (valid && callBackLock.tryLock(50, TimeUnit.MILLISECONDS)) {
call.setMessageId(messageId.incrementAndGet());
lockSet = true;
Callback callback = new Callback(call);
callbackHandler.handleCallbackOneway(callback);
callbackHandler.handleCallbackOneway(callback, SUPER_DUPER_BUGGY_AND_FASTEST_ASYNC_CONNECTION);
}
} catch (InterruptedException ex) {
logger.warn("SESSION LOCK - fireCallback - userId: " + userId + " messageId: " + call.getMessageId(), ex);
// already sending another command (connection problem?)
if (call.getMethod().equals(ClientCallbackMethod.GAME_INIT)
|| call.getMethod().equals(ClientCallbackMethod.START_GAME)) {
// it's ok use case, user has connection problem so can't send game init (see sendInfoAboutPlayersNotJoinedYetAndTryToFixIt)
} else {
logger.warn("SESSION LOCK, possible connection problem - fireCallback - userId: " + userId + " messageId: " + call.getMessageId(), ex);
}
} catch (HandleCallbackException ex) {
// something wrong, maybe connection problem
logger.warn("SESSION CALLBACK EXCEPTION - " + ThreadUtils.findRootException(ex) + ", userId " + userId + ", messageId: " + call.getMessageId(), ex);
// do not send data anymore (user must reconnect)
this.valid = false;
managerFactory.userManager().getUser(userId).ifPresent(user -> {
user.setUserState(User.UserState.Disconnected);
logger.warn("SESSION CALLBACK EXCEPTION - " + user.getName() + " userId " + userId + " messageId: " + call.getMessageId() + " - cause: " + getBasicCause(ex).toString());
logger.trace("Stack trace:", ex);
managerFactory.sessionManager().disconnect(sessionId, LostConnection);
});
} catch (Exception ex) {
logger.warn("Unspecific exception:", ex);
managerFactory.sessionManager().disconnect(sessionId, LostConnection, true);
} catch (Throwable ex) {
logger.error("SESSION CALLBACK UNKNOWN EXCEPTION - " + ThreadUtils.findRootException(ex) + ", userId " + userId + ", messageId: " + call.getMessageId(), ex);
// do not send data anymore (user must reconnect)
this.valid = false;
managerFactory.sessionManager().disconnect(sessionId, LostConnection, true);
} finally {
if (lockSet) {
callBackLock.unlock();

View file

@ -2,8 +2,8 @@ package mage.server;
import mage.MageException;
import mage.players.net.UserData;
import mage.server.managers.SessionManager;
import mage.server.managers.ManagerFactory;
import mage.server.managers.SessionManager;
import org.apache.log4j.Logger;
import org.jboss.remoting.callback.InvokerCallbackHandler;
@ -12,7 +12,9 @@ import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author BetaSteward_at_googlemail.com
* Server: manage all connections (first connection, after auth, after anon)
*
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public class SessionManagerImpl implements SessionManager {
@ -27,18 +29,7 @@ public class SessionManagerImpl implements SessionManager {
@Override
public Optional<Session> getSession(@Nonnull String sessionId) {
Session session = sessions.get(sessionId);
if (session == null) {
logger.trace("Session with sessionId " + sessionId + " is not found");
return Optional.empty();
}
if (session.getUserId() != null && !managerFactory.userManager().getUser(session.getUserId()).isPresent()) {
logger.error("User for session " + sessionId + " with userId " + session.getUserId() + " is missing. Session removed.");
// can happen if user from same host signs in multiple time with multiple clients, after they disconnect with one client
disconnect(sessionId, DisconnectReason.ConnectingOtherInstance, session); // direct disconnect
return Optional.empty();
}
return Optional.of(session);
return Optional.ofNullable(sessions.getOrDefault(sessionId, null));
}
@Override
@ -67,18 +58,33 @@ public class SessionManagerImpl implements SessionManager {
}
@Override
public boolean connectUser(String sessionId, String userName, String password, String userIdStr) throws MageException {
public boolean connectUser(String sessionId, String restoreSessionId, String userName, String password, String userInfo, boolean detailsMode) throws MageException {
Session session = sessions.get(sessionId);
if (session != null) {
String returnMessage = session.connectUser(userName, password);
if (returnMessage == null) {
logger.info(userName + " connected to server");
String errorMessage = session.connectUser(userName, password, restoreSessionId);
if (errorMessage == null) {
logger.info(userName + " connected to server by sessionId " + sessionId
+ (restoreSessionId.isEmpty() ? "" : ", restoreSessionId " + restoreSessionId));
if (detailsMode) {
logger.info("- details: " + userInfo);
}
logger.debug("- userId: " + session.getUserId());
logger.debug("- sessionId: " + sessionId);
logger.debug("- restoreSessionId: " + restoreSessionId);
logger.debug("- host: " + session.getHost());
return true;
} else {
logger.debug(userName + " not connected: " + returnMessage);
logger.debug(userName + " not connected: " + errorMessage);
// send error to client side
try {
// ddos/bruteforce protection
Thread.sleep(3000);
} catch (InterruptedException e) {
logger.fatal("waiting of error message had failed", e);
Thread.currentThread().interrupt();
}
session.sendErrorMessageToClient(errorMessage);
}
} else {
logger.error(userName + " tried to connect with no sessionId");
@ -106,46 +112,27 @@ public class SessionManagerImpl implements SessionManager {
}
@Override
public void disconnect(String sessionId, DisconnectReason reason) {
disconnect(sessionId, reason, null);
}
@Override
public void disconnect(String sessionId, DisconnectReason reason, Session directSession) {
if (directSession == null) {
// find real session to disconnects
getSession(sessionId).ifPresent(session -> {
if (!isValidSession(sessionId)) {
// session was removed meanwhile by another thread so we can return
return;
}
logger.debug("DISCONNECT " + reason.toString() + " - sessionId: " + sessionId);
sessions.remove(sessionId);
switch (reason) {
case AdminDisconnect:
session.kill(reason);
break;
case ConnectingOtherInstance:
case Disconnected: // regular session end or wrong client version
managerFactory.userManager().disconnect(session.getUserId(), reason);
break;
case SessionExpired: // session ends after no reconnect happens in the defined time span
break;
case LostConnection: // user lost connection - session expires countdown starts
session.userLostConnection();
managerFactory.userManager().disconnect(session.getUserId(), reason);
break;
default:
logger.trace("endSession: unexpected reason " + reason.toString() + " - sessionId: " + sessionId);
}
});
} else {
// direct session to disconnects
sessions.remove(sessionId);
directSession.kill(reason);
public void disconnect(String sessionId, DisconnectReason reason, boolean checkUserDisconnection) {
Session session = getSession(sessionId).orElse(null);
if (session == null) {
return;
}
}
if (!isValidSession(sessionId)) {
logger.info("DISCONNECT session, already invalid: " + reason + " - sessionId: " + sessionId);
// session was removed meanwhile by another thread
// TODO: otudated code? If no logs on server then remove that code, 2023-12-06
return;
}
if (checkUserDisconnection) {
managerFactory.userManager().getUser(session.getUserId()).ifPresent(user -> {
user.onLostConnection(reason);
});
}
sessions.remove(sessionId);
}
/**
* Admin requested the disconnect of a user
@ -154,36 +141,24 @@ public class SessionManagerImpl implements SessionManager {
* @param userSessionId
*/
@Override
public void disconnectUser(String sessionId, String userSessionId) {
public void disconnectAnother(String sessionId, String userSessionId) {
if (!checkAdminAccess(sessionId)) {
return;
}
getUserFromSession(sessionId).ifPresent(admin -> {
Optional<User> u = getUserFromSession(userSessionId);
if (u.isPresent()) {
User user = u.get();
user.showUserMessage("Admin action", "Your session was disconnected by admin");
admin.showUserMessage("Admin result", "User " + user.getName() + " was disconnected");
disconnect(userSessionId, DisconnectReason.AdminDisconnect);
} else {
admin.showUserMessage("Admin result", "User with sessionId " + userSessionId + " could not be found");
}
});
User admin = getUserFromSession(sessionId).orElse(null);
User user = getUserFromSession(userSessionId).orElse(null);
if (admin == null || user == null) {
return;
}
user.showUserMessage("Admin action", "Your session was disconnected by admin");
disconnect(userSessionId, DisconnectReason.DisconnectedByAdmin, true);
admin.showUserMessage("Admin result", "User " + user.getName() + " was disconnected");
}
private Optional<User> getUserFromSession(String sessionId) {
return getSession(sessionId)
.flatMap(s -> managerFactory.userManager().getUser(s.getUserId()));
}
@Override
public void endUserSession(String sessionId, String userSessionId) {
if (!checkAdminAccess(sessionId)) {
return;
}
disconnect(userSessionId, DisconnectReason.AdminDisconnect);
return getSession(sessionId).flatMap(s -> managerFactory.userManager().getUser(s.getUserId()));
}
@Override
@ -233,4 +208,10 @@ public class SessionManagerImpl implements SessionManager {
}
session.sendErrorMessageToClient(message);
}
@Override
public void checkHealth() {
//logger.info("Checking sessions...");
// TODO: add lone sessions check and report (with lost user)
}
}

View file

@ -14,8 +14,8 @@ import mage.game.tournament.TournamentOptions;
import mage.game.tournament.TournamentPlayer;
import mage.players.PlayerType;
import mage.server.game.GameController;
import mage.server.managers.TableManager;
import mage.server.managers.ManagerFactory;
import mage.server.managers.TableManager;
import org.apache.log4j.Logger;
import java.text.DateFormat;
@ -23,9 +23,6 @@ import java.text.SimpleDateFormat;
import java.util.*;
import java.util.Map.Entry;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
@ -34,7 +31,6 @@ import java.util.concurrent.locks.ReentrantReadWriteLock;
* @author BetaSteward_at_googlemail.com
*/
public class TableManagerImpl implements TableManager {
protected final ScheduledExecutorService expireExecutor = Executors.newSingleThreadScheduledExecutor();
private final ManagerFactory managerFactory;
private final Logger logger = Logger.getLogger(TableManagerImpl.class);
@ -46,22 +42,11 @@ public class TableManagerImpl implements TableManager {
private final ConcurrentHashMap<UUID, Table> tables = new ConcurrentHashMap<>();
private final ReadWriteLock tablesLock = new ReentrantReadWriteLock();
// defines how often checking process should be run on server (in minutes)
private static final int TABLE_HEALTH_CHECK_TIMEOUT_MINS = 10;
public TableManagerImpl(ManagerFactory managerFactory) {
this.managerFactory = managerFactory;
}
public void init() {
expireExecutor.scheduleAtFixedRate(() -> {
try {
managerFactory.chatManager().clearUserMessageStorage();
checkTableHealthState();
} catch (Exception ex) {
logger.fatal("Check table health state job error:", ex);
}
}, TABLE_HEALTH_CHECK_TIMEOUT_MINS, TABLE_HEALTH_CHECK_TIMEOUT_MINS, TimeUnit.MINUTES);
}
@Override
@ -426,7 +411,7 @@ public class TableManagerImpl implements TableManager {
List<ChatSession> chatSessions = managerFactory.chatManager().getChatSessions();
logger.debug("------- ChatSessions: " + chatSessions.size() + " ----------------------------------");
for (ChatSession chatSession : chatSessions) {
logger.debug(chatSession.getChatId() + " " + formatter.format(chatSession.getCreateTime()) + ' ' + chatSession.getInfo() + ' ' + chatSession.getClients().values().toString());
logger.debug(chatSession.getChatId() + " " + formatter.format(chatSession.getCreateTime()) + ' ' + chatSession.getInfo() + ' ' + chatSession.getUsers().values().toString());
}
logger.debug("------- Games: " + managerFactory.gameManager().getNumberActiveGames() + " --------------------------------------------");
logger.debug(" Active Game Worker: " + managerFactory.threadExecutor().getActiveThreads(managerFactory.threadExecutor().getGameExecutor()));
@ -436,7 +421,8 @@ public class TableManagerImpl implements TableManager {
logger.debug("--- Server state END ------------------------------------------");
}
private void checkTableHealthState() {
private void removeOutdatedTables() {
// TODO: need research and check - is it actual code or not, 2023-12-06
if (logger.isDebugEnabled()) {
debugServerState();
}
@ -465,6 +451,13 @@ public class TableManagerImpl implements TableManager {
}
}
logger.debug("TABLE HEALTH CHECK - END");
}
@Override
public void checkHealth() {
//logger.info("Checking tables...");
// TODO: add memory reports
// TODO: add broken tables check and report (without seats, without gamecontroller, without active players, too long playing, etc)
removeOutdatedTables();
}
}

View file

@ -29,12 +29,14 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
/**
* @author BetaSteward_at_googlemail.com
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public class User {
private static final Logger logger = Logger.getLogger(User.class);
public static final String ADMIN_NAME = "Admin"; // if you change here then change in SessionImpl too
public enum UserState {
Created, // Used if user is created an not connected to the session
Connected, // Used if user is correctly connected
@ -56,6 +58,7 @@ public class User {
private final Map<UUID, Deck> sideboarding;
private final List<UUID> watchedGames;
private String sessionId;
private String restoreSessionId; // keep sessionId for possible restore on reconnect
private String pingInfo = "";
private Date lastActivity;
private UserState userState;
@ -118,6 +121,10 @@ public class User {
return sessionId;
}
public String getRestoreSessionId() {
return restoreSessionId;
}
public Date getChatLockedUntil() {
return chatLockedUntil;
}
@ -132,19 +139,10 @@ public class User {
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
if (sessionId.isEmpty()) {
setUserState(UserState.Disconnected);
lostConnection();
logger.trace("USER - lost connection: " + userName + " id: " + userId);
}
} else if (userState == UserState.Created) {
setUserState(UserState.Connected);
logger.trace("USER - created: " + userName + " id: " + userId);
} else {
setUserState(UserState.Connected);
reconnect();
logger.trace("USER - reconnected: " + userName + " id: " + userId);
}
public void setRestoreSessionId(String restoreSessionId) {
this.restoreSessionId = restoreSessionId;
}
public void setClientVersion(String clientVersion) {
@ -178,14 +176,44 @@ public class User {
updateAuthorizedUser();
}
public void lostConnection() {
// Because watched games don't get restored after reconnection call stop watching
/**
* Shared code for any user disconnection
* <p>
* Full user instance remove will be done by reconnect or by server health check
*
* @param reason
*/
public void onLostConnection(DisconnectReason reason) {
// stats for bad connections only
if (reason != DisconnectReason.DisconnectedByUser
&& reason != DisconnectReason.DisconnectedByUserButKeepTables) {
ServerMessagesUtil.instance.incLostConnection();
}
// chats restored on reconnection from a scrach by game panels - so don't keep it
managerFactory.chatManager().removeUser(userId, reason);
// watched games don't get restored after reconnection call - so don't keep it
for (Iterator<UUID> iterator = watchedGames.iterator(); iterator.hasNext(); ) {
UUID gameId = iterator.next();
managerFactory.gameManager().stopWatching(gameId, userId);
iterator.remove();
}
ServerMessagesUtil.instance.incLostConnection();
// game tables
if (reason.isRemoveUserTables) {
// full disconnection
removeUserFromAllTables(reason); // it's duplicates some chat/watch code inside, but it ok
setUserState(UserState.Offline);
managerFactory.userManager().removeUser(getId());
} else {
setUserState(UserState.Disconnected);
}
// kill active session
String oldSessionId = sessionId;
setSessionId("");
managerFactory.sessionManager().disconnect(oldSessionId, reason, false);
}
public boolean isConnected() {
@ -317,9 +345,7 @@ public class User {
this.pingInfo = pingInfo;
}
lastActivity = new Date();
if (userState == UserState.Disconnected) { // this can happen if user reconnects very fast after disconnect
setUserState(UserState.Connected);
}
setUserState(UserState.Connected);
}
public boolean isExpired(Date expired) {
@ -332,11 +358,19 @@ public class User {
}
private void reconnect() {
logger.trace(userName + " started reconnect");
/**
* Restore all active tables and another data
*/
public void onReconnect() {
// TODO: research tables restore - it's old code, so check table state, timers, actions, ?concede?, ?skips?
ServerMessagesUtil.instance.incReconnects();
// active tables
for (Entry<UUID, Table> entry : tables.entrySet()) {
ccJoinedTable(entry.getValue().getRoomId(), entry.getValue().getId(), entry.getValue().isTournament());
}
// active tourneys
for (Iterator<Entry<UUID, UUID>> iterator = userTournaments.entrySet().iterator(); iterator.hasNext(); ) {
Entry<UUID, UUID> next = iterator.next();
Optional<TournamentController> tournamentController = managerFactory.tournamentManager().getTournamentController(next.getValue());
@ -347,21 +381,27 @@ public class User {
iterator.remove(); // tournament has ended meanwhile
}
}
// active games
for (Entry<UUID, GameSessionPlayer> entry : gameSessions.entrySet()) {
ccGameStarted(entry.getValue().getGameId(), entry.getKey());
entry.getValue().init();
managerFactory.gameManager().sendPlayerString(entry.getValue().getGameId(), userId, "");
}
// active drafts
for (Entry<UUID, DraftSession> entry : draftSessions.entrySet()) {
ccDraftStarted(entry.getValue().getDraftId(), entry.getKey());
entry.getValue().init();
entry.getValue().update();
}
// active constructing
for (Entry<UUID, TournamentSession> entry : constructing.entrySet()) {
entry.getValue().construct(0); // TODO: Check if this is correct
}
// active sideboarding
for (Entry<UUID, Deck> entry : sideboarding.entrySet()) {
Optional<TableController> controller = managerFactory.tableManager().getController(entry.getKey());
if (controller.isPresent()) {
@ -372,8 +412,6 @@ public class User {
logger.debug(getName() + " reconnects during sideboarding but tableId not found: " + entry.getKey());
}
}
ServerMessagesUtil.instance.incReconnects();
logger.trace(userName + " ended reconnect");
}
public void addGame(UUID playerId, GameSessionPlayer gameSession) {

View file

@ -1,8 +1,7 @@
package mage.server;
import mage.server.User.UserState;
import mage.server.managers.UserManager;
import mage.server.managers.ManagerFactory;
import mage.server.managers.UserManager;
import mage.server.record.UserStats;
import mage.server.record.UserStatsRepository;
import mage.view.UserView;
@ -15,19 +14,19 @@ import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* manages users - if a user is disconnected and 10 minutes have passed with no
* activity the user is removed
* Server: manage active user instances (connected to the server)
*
* @author BetaSteward_at_googlemail.com
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public class UserManagerImpl implements UserManager {
private static final int SERVER_TIMEOUTS_USER_INFORM_OPPONENTS_ABOUT_DISCONNECT_AFTER_SECS = 30; // send to chat info about disconnection troubles, must be more than ping timeout
private static final int SERVER_TIMEOUTS_USER_DISCONNECT_FROM_SERVER_AFTER_SECS = 3 * 60; // removes from all games and chats too (can be seen in users list with disconnected status)
private static final int SERVER_TIMEOUTS_USER_REMOVE_FROM_SERVER_AFTER_SECS = 8 * 60; // removes from users list
// timeouts on user's activity (on connection problems)
private static final int USER_CONNECTION_TIMEOUTS_CHECK_SECS = 30; // TODO: replace to 60 before merge
private static final int USER_CONNECTION_TIMEOUT_INFORM_AFTER_SECS = 30; // inform user's opponents about problem
private static final int USER_CONNECTION_TIMEOUT_SESSION_EXPIRE_AFTER_SECS = 3 * 60; // session expire - remove from all tables and chats (can't reconnect after it)
private static final int USER_CONNECTION_TIMEOUT_REMOVE_FROM_SERVER_SECS = 8 * 60; // removes from users list
private static final int SERVER_TIMEOUTS_USER_EXPIRE_CHECK_SECS = 60;
private static final int SERVER_TIMEOUTS_USERS_LIST_UPDATE_SECS = 4; // server side updates (client use own timeouts to request users list)
private static final int SERVER_USERS_LIST_UPDATE_SECS = 4; // server side updates (client use own timeouts to request users list)
private static final Logger logger = Logger.getLogger(UserManagerImpl.class);
@ -49,8 +48,8 @@ public class UserManagerImpl implements UserManager {
public void init() {
USER_EXECUTOR = managerFactory.threadExecutor().getCallExecutor();
expireExecutor.scheduleAtFixedRate(this::checkExpired, SERVER_TIMEOUTS_USER_EXPIRE_CHECK_SECS, SERVER_TIMEOUTS_USER_EXPIRE_CHECK_SECS, TimeUnit.SECONDS);
userListExecutor.scheduleAtFixedRate(this::updateUserInfoList, SERVER_TIMEOUTS_USERS_LIST_UPDATE_SECS, SERVER_TIMEOUTS_USERS_LIST_UPDATE_SECS, TimeUnit.SECONDS);
expireExecutor.scheduleAtFixedRate(this::checkExpired, USER_CONNECTION_TIMEOUTS_CHECK_SECS, USER_CONNECTION_TIMEOUTS_CHECK_SECS, TimeUnit.SECONDS);
userListExecutor.scheduleAtFixedRate(this::updateUserInfoList, SERVER_USERS_LIST_UPDATE_SECS, SERVER_USERS_LIST_UPDATE_SECS, TimeUnit.SECONDS);
}
@Override
@ -71,11 +70,16 @@ public class UserManagerImpl implements UserManager {
@Override
public Optional<User> getUser(UUID userId) {
if (!users.containsKey(userId)) {
//logger.warn(String.format("User with id %s could not be found", userId), new Throwable()); // TODO: remove after session freezes fixed
if (userId == null) {
return Optional.empty();
} else {
return Optional.of(users.get(userId));
}
final Lock r = lock.readLock();
r.lock();
try {
return Optional.ofNullable(users.getOrDefault(userId, null));
} finally {
r.unlock();
}
}
@ -84,12 +88,13 @@ public class UserManagerImpl implements UserManager {
final Lock r = lock.readLock();
r.lock();
try {
return users.values().stream().filter(user -> user.getName().equals(userName))
return users.values()
.stream()
.filter(user -> user.getName().equals(userName))
.findFirst();
} finally {
r.unlock();
}
}
@Override
@ -108,9 +113,9 @@ public class UserManagerImpl implements UserManager {
@Override
public boolean connectToSession(String sessionId, UUID userId) {
if (userId != null) {
User user = users.get(userId);
if (user != null) {
user.setSessionId(sessionId);
Optional<User> user = getUser(userId);
if (user.isPresent()) {
user.get().setSessionId(sessionId);
return true;
}
}
@ -119,45 +124,17 @@ public class UserManagerImpl implements UserManager {
@Override
public void disconnect(UUID userId, DisconnectReason reason) {
Optional<User> user = getUser(userId);
if (user.isPresent()) {
user.get().setSessionId("");
if (reason == DisconnectReason.Disconnected) {
removeUserFromAllTablesAndChat(userId, reason);
user.get().setUserState(UserState.Offline);
}
User user = getUser(userId).orElse(null);
if (user != null) {
user.onLostConnection(reason);
}
}
@Override
public boolean isAdmin(UUID userId) {
if (userId != null) {
User user = users.get(userId);
if (user != null) {
return user.getName().equals("Admin");
}
}
return false;
}
@Override
public void removeUserFromAllTablesAndChat(final UUID userId, final DisconnectReason reason) {
if (userId != null) {
getUser(userId).ifPresent(user
-> USER_EXECUTOR.execute(
() -> {
try {
logger.info("USER REMOVE - " + user.getName() + " (" + reason.toString() + ") userId: " + userId + " [" + user.getGameInfo() + ']');
user.removeUserFromAllTables(reason);
managerFactory.chatManager().removeUser(user.getId(), reason);
logger.debug("USER REMOVE END - " + user.getName());
} catch (Exception ex) {
handleException(ex);
}
}
));
}
return getUser(userId)
.filter(u -> u.getName().equals(User.ADMIN_NAME))
.isPresent();
}
@Override
@ -195,13 +172,13 @@ public class UserManagerImpl implements UserManager {
*/
private void checkExpired() {
try {
Calendar calendarInform = Calendar.getInstance();
calendarInform.add(Calendar.SECOND, -1 * SERVER_TIMEOUTS_USER_INFORM_OPPONENTS_ABOUT_DISCONNECT_AFTER_SECS);
Calendar calendarExp = Calendar.getInstance();
calendarExp.add(Calendar.SECOND, -1 * SERVER_TIMEOUTS_USER_DISCONNECT_FROM_SERVER_AFTER_SECS);
Calendar calendarRemove = Calendar.getInstance();
calendarRemove.add(Calendar.SECOND, -1 * SERVER_TIMEOUTS_USER_REMOVE_FROM_SERVER_AFTER_SECS);
List<User> toRemove = new ArrayList<>();
Calendar calInform = Calendar.getInstance();
calInform.add(Calendar.SECOND, -1 * USER_CONNECTION_TIMEOUT_INFORM_AFTER_SECS);
Calendar calSessionExpire = Calendar.getInstance();
calSessionExpire.add(Calendar.SECOND, -1 * USER_CONNECTION_TIMEOUT_SESSION_EXPIRE_AFTER_SECS);
Calendar calUserRemove = Calendar.getInstance();
calUserRemove.add(Calendar.SECOND, -1 * USER_CONNECTION_TIMEOUT_REMOVE_FROM_SERVER_SECS);
List<User> usersToRemove = new ArrayList<>();
logger.debug("Start Check Expired");
List<User> userList = new ArrayList<>();
final Lock r = lock.readLock();
@ -213,37 +190,60 @@ public class UserManagerImpl implements UserManager {
}
for (User user : userList) {
try {
if (user.getUserState() != UserState.Offline
&& user.isExpired(calendarInform.getTime())) {
long secsInfo = (Calendar.getInstance().getTimeInMillis() - user.getLastActivity().getTime()) / 1000;
informUserOpponents(user.getId(), user.getName() + " got connection problem for " + secsInfo + " secs");
}
// expire logic:
// - any user actions will update user's last activity date
// - expire code schedules to check last activity every few seconds (minutes)
// - if something outdated then it will be removed from a server
// - user lifecycle: created -> connected/disconnected (bad connection, bad session) -> offline
if (user.getUserState() == UserState.Offline) {
if (user.isExpired(calendarRemove.getTime())) {
// removes from users list
toRemove.add(user);
boolean isBadConnection = user.isExpired(calInform.getTime());
boolean isBadSession = user.isExpired(calSessionExpire.getTime());
boolean isBadUser = user.isExpired(calUserRemove.getTime());
switch (user.getUserState()) {
case Created: {
// ignore
break;
}
} else {
if (user.isExpired(calendarExp.getTime())) {
// set disconnected status and removes from all activities (tourney/tables/games/drafts/chats)
if (user.getUserState() == UserState.Connected) {
user.lostConnection();
disconnect(user.getId(), DisconnectReason.BecameInactive);
case Offline: {
// remove user from a server (users list for GUI)
if (isBadUser) {
usersToRemove.add(user);
}
removeUserFromAllTablesAndChat(user.getId(), DisconnectReason.SessionExpired);
user.setUserState(UserState.Offline);
break;
}
case Connected:
case Disconnected: {
if (isBadConnection) {
long secsInfo = (Calendar.getInstance().getTimeInMillis() - user.getLastActivity().getTime()) / 1000;
informUserOpponents(user.getId(), String.format("%s catch connection problems for %s secs (left before expire: %d secs)",
user.getName(),
secsInfo,
Math.max(0, USER_CONNECTION_TIMEOUT_SESSION_EXPIRE_AFTER_SECS - secsInfo)
));
}
if (isBadSession) {
// full disconnect
disconnect(user.getId(), DisconnectReason.SessionExpired);
}
break;
}
default: {
throw new IllegalArgumentException("Unknown user state: " + user.getUserState());
}
}
} catch (Exception ex) {
handleException(ex);
}
}
logger.debug("Users to remove " + toRemove.size());
final Lock w = lock.readLock();
logger.debug("Users to remove " + usersToRemove.size());
final Lock w = lock.writeLock();
w.lock();
try {
for (User user : toRemove) {
for (User user : usersToRemove) {
users.remove(user.getId());
}
} finally {
@ -255,6 +255,28 @@ public class UserManagerImpl implements UserManager {
}
}
/**
* Remove user instance from a server
* Warning, call it after all tables/chats and other user related data removes
*
* @param userId
*/
@Override
public void removeUser(UUID userId) {
try {
final Lock w = lock.writeLock();
w.lock();
try {
users.remove(userId);
} finally {
w.unlock();
}
} catch (Exception e) {
handleException(e);
}
}
/**
* This method recreated the user list that will be sent to all clients
*/
@ -322,4 +344,10 @@ public class UserManagerImpl implements UserManager {
}
});
}
@Override
public void checkHealth() {
//logger.info("Checking users...");
// TODO: add broken users check and report (too long without sessions)
}
}

View file

@ -52,11 +52,16 @@ public class DraftSession {
if (!killed) {
Optional<User> user = managerFactory.userManager().getUser(userId);
if (user.isPresent()) {
int remaining;
if (futureTimeout != null && !futureTimeout.isDone()) {
int remaining = (int) futureTimeout.getDelay(TimeUnit.SECONDS);
user.get().fireCallback(new ClientCallback(ClientCallbackMethod.DRAFT_INIT, draft.getId(),
new DraftClientMessage(getDraftView(), getDraftPickView(remaining))));
// picking already runs
remaining = (int) futureTimeout.getDelay(TimeUnit.SECONDS);
} else {
// picking not started yet
remaining = draft.getPickTimeout();
}
user.get().fireCallback(new ClientCallback(ClientCallbackMethod.DRAFT_INIT, draft.getId(),
new DraftClientMessage(getDraftView(), getDraftPickView(remaining))));
return true;
}
}

View file

@ -5,15 +5,13 @@ import mage.abilities.Ability;
import mage.abilities.common.PassAbility;
import mage.cards.Card;
import mage.cards.Cards;
import mage.cards.decks.Deck;
import mage.cards.decks.DeckCardLists;
import mage.cards.repository.CardInfo;
import mage.cards.repository.CardRepository;
import mage.choices.Choice;
import mage.constants.ManaType;
import mage.constants.PlayerAction;
import mage.constants.Zone;
import mage.game.*;
import mage.game.Game;
import mage.game.GameOptions;
import mage.game.GameState;
import mage.game.Table;
import mage.game.command.Plane;
import mage.game.events.Listener;
import mage.game.events.PlayerQueryEvent;
@ -46,7 +44,7 @@ import java.util.stream.Collectors;
import java.util.zip.GZIPOutputStream;
/**
* @author BetaSteward_at_googlemail.com
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public class GameController implements GameCallback {
@ -317,7 +315,6 @@ public class GameController implements GameCallback {
}
user.get().addGame(playerId, gameSession);
logger.debug("Player " + player.getName() + ' ' + playerId + " has " + joinType + " gameId: " + game.getId());
// TODO: force user update?!
managerFactory.chatManager().broadcast(chatId, "", game.getPlayer(playerId).getLogName() + " has " + joinType + " the game", MessageColor.ORANGE, true, game, MessageType.GAME, null);
checkStart();
}
@ -347,7 +344,7 @@ public class GameController implements GameCallback {
}
private void sendInfoAboutPlayersNotJoinedYetAndTryToFixIt() {
// runs every 5 secs untill all players join
// runs every 10 secs untill all players join
for (Player player : game.getPlayers().values()) {
if (player.canRespond() && player.isHuman()) {
Optional<User> requestedUser = getUserByPlayerId(player.getId());
@ -358,7 +355,7 @@ public class GameController implements GameCallback {
// join the game because player has not joined or was removed because of disconnect
String problemPlayerFixes;
user.removeConstructing(player.getId());
managerFactory.gameManager().joinGame(game.getId(), user.getId());
managerFactory.gameManager().joinGame(game.getId(), user.getId()); // will generate new session on empty
logger.warn("Forced join of player " + player.getName() + " (" + user.getUserState() + ") to gameId: " + game.getId());
if (user.isConnected()) {
// init game session, see reconnect()
@ -370,9 +367,8 @@ public class GameController implements GameCallback {
session.init();
managerFactory.gameManager().sendPlayerString(session.getGameId(), user.getId(), "");
} else {
problemPlayerFixes = "leave on broken game session";
logger.error("Can't find game session for forced join, leave it: player " + player.getName() + " in gameId: " + game.getId());
player.leave();
throw new IllegalStateException("Wrong code usage: session can't be null cause it created in forced joinGame already");
//player.leave();
}
} else {
problemPlayerFixes = "leave on disconnected";
@ -868,12 +864,12 @@ public class GameController implements GameCallback {
}
private synchronized void multiAmount(UUID playerId, final List<MultiAmountMessage> messages,
final int min, final int max, final Map<String, Serializable> options)
final int min, final int max, final Map<String, Serializable> options)
throws MageException {
perform(playerId, playerId1 -> getGameSession(playerId1).getMultiAmount(messages, min, max, options));
}
private void informOthers(UUID playerId) throws MageException {
private void informOthers(UUID playerId) {
StringBuilder message = new StringBuilder();
if (game.getStep() != null) {
message.append(game.getTurnStepType().toString()).append(" - ");
@ -889,7 +885,7 @@ public class GameController implements GameCallback {
}
}
private void informOthers(List<UUID> players) throws MageException {
private void informOthers(List<UUID> players) {
// first player is always original controller
Player controller = null;
if (players != null && !players.isEmpty()) {
@ -974,18 +970,19 @@ public class GameController implements GameCallback {
*
* @param playerId
* @param command
* @throws MageException
*/
private void perform(UUID playerId, Command command) throws MageException {
private void perform(UUID playerId, Command command) {
perform(playerId, command, true);
}
private void perform(UUID playerId, Command command, boolean informOthers) throws MageException {
private void perform(UUID playerId, Command command, boolean informOthers) {
if (game.getPlayer(playerId).isGameUnderControl()) { // is the player controlling it's own turn
if (gameSessions.containsKey(playerId)) {
setupTimeout(playerId);
command.execute(playerId);
}
// TODO: if watcher disconnects then game freezes with active timer, must be fix for such use case
// same for another player (can be fixed by super-duper connection)
if (informOthers) {
informOthers(playerId);
}
@ -997,6 +994,8 @@ public class GameController implements GameCallback {
command.execute(uuid);
}
}
// TODO: if watcher disconnects then game freeze with active timer, must be fix for such use case
// same for another player (can be fixed by super-duper connection)
if (informOthers) {
informOthers(players);
}

View file

@ -31,6 +31,8 @@ public class GamesRoomImpl extends RoomImpl implements GamesRoom, Serializable {
private static final Logger LOGGER = Logger.getLogger(GamesRoomImpl.class);
private static final int MAX_FINISHED_TABLES = 50;
private static final ScheduledExecutorService UPDATE_EXECUTOR = Executors.newSingleThreadScheduledExecutor();
private static List<TableView> tableView = new ArrayList<>();
private static List<MatchView> matchView = new ArrayList<>();
@ -65,7 +67,7 @@ public class GamesRoomImpl extends RoomImpl implements GamesRoom, Serializable {
for (Table table : allTables) {
if (table.getState() != TableState.FINISHED) {
tableList.add(new TableView(table));
} else if (matchList.size() < 50) {
} else if (matchList.size() < MAX_FINISHED_TABLES) {
matchList.add(new MatchView(table));
} else {
// more since 50 matches finished since this match so removeUserFromAllTablesAndChat it
@ -79,7 +81,8 @@ public class GamesRoomImpl extends RoomImpl implements GamesRoom, Serializable {
matchView = matchList;
List<UsersView> users = new ArrayList<>();
for (User user : managerFactory.userManager().getUsers()) {
if (user.getUserState() != User.UserState.Offline && !user.getName().equals("Admin")) {
if (user.getUserState() != User.UserState.Offline
&& !user.getName().equals(User.ADMIN_NAME)) {
try {
users.add(new UsersView(user.getUserData().getFlagName(), user.getName(),
user.getMatchHistory(), user.getMatchQuitRatio(), user.getTourneyHistory(),
@ -244,12 +247,4 @@ class TableListSorter implements Comparator<Table> {
}
return 0;
}
}
class UserNameSorter implements Comparator<UsersView> {
@Override
public int compare(UsersView one, UsersView two) {
return one.getUserName().compareToIgnoreCase(two.getUserName());
}
}
}

View file

@ -10,27 +10,24 @@ import java.util.List;
import java.util.UUID;
public interface ChatManager {
UUID createChatSession(String info);
void joinChat(UUID chatId, UUID userId);
void clearUserMessageStorage();
void leaveChat(UUID chatId, UUID userId);
void destroyChatSession(UUID chatId);
void broadcast(UUID chatId, String userName, String message, ChatMessage.MessageColor color, boolean withTime, Game game, ChatMessage.MessageType messageType, ChatMessage.SoundToPlay soundToPlay);
void broadcast(UUID userId, String message, ChatMessage.MessageColor color) throws UserNotFoundException;
void sendReconnectMessage(UUID userId);
void sendLostConnectionMessage(UUID userId, DisconnectReason reason);
void sendMessageToUserChats(UUID userId, String message);
void removeUser(UUID userId, DisconnectReason reason);
List<ChatSession> getChatSessions();
void checkHealth();
}

View file

@ -18,19 +18,22 @@ public interface SessionManager {
boolean registerUser(String sessionId, String userName, String password, String email) throws MageException;
boolean connectUser(String sessionId, String userName, String password, String userIdStr) throws MageException;
boolean connectUser(String sessionId, String restoreSessionId, String userName, String password, String userInfo, boolean detailsMode) throws MageException;
boolean connectAdmin(String sessionId);
boolean setUserData(String userName, String sessionId, UserData userData, String clientVersion, String userIdStr) throws MageException;
void disconnect(String sessionId, DisconnectReason reason);
/**
* Disconnect from a session side, e.g. on connection error
*
* @param sessionId
* @param reason
* @param checkUserDisconnection check and remove user's tables too, use it by default
*/
void disconnect(String sessionId, DisconnectReason reason, boolean checkUserDisconnection);
void disconnect(String sessionId, DisconnectReason reason, Session directSession);
void disconnectUser(String sessionId, String userSessionId);
void endUserSession(String sessionId, String userSessionId);
void disconnectAnother(String sessionId, String userSessionId);
boolean checkAdminAccess(String sessionId);
@ -41,4 +44,6 @@ public interface SessionManager {
boolean extendUserSession(String sessionId, String pingInfo);
void sendErrorMessageToClient(String sessionId, String message);
void checkHealth();
}

View file

@ -83,4 +83,6 @@ public interface TableManager {
void removeTable(UUID tableId);
void debugServerState();
void checkHealth();
}

View file

@ -1,7 +1,9 @@
package mage.server.managers;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
public interface ThreadExecutor {
int getActiveThreads(ExecutorService executerService);
@ -13,4 +15,6 @@ public interface ThreadExecutor {
ScheduledExecutorService getTimeoutExecutor();
ScheduledExecutorService getTimeoutIdleExecutor();
ScheduledExecutorService getServerHealthExecutor();
}

View file

@ -11,6 +11,7 @@ import java.util.Optional;
import java.util.UUID;
public interface UserManager {
Optional<User> createUser(String userName, String host, AuthorizedUser authorizedUser);
Optional<User> getUser(UUID userId);
@ -25,12 +26,12 @@ public interface UserManager {
boolean isAdmin(UUID userId);
void removeUserFromAllTablesAndChat(UUID userId, DisconnectReason reason);
void informUserOpponents(UUID userId, String message);
boolean extendUserSession(UUID userId, String pingInfo);
void removeUser(UUID userId);
List<UserView> getUserInfoList();
void handleException(Exception ex);
@ -38,4 +39,6 @@ public interface UserManager {
String getUserHistory(String userName);
void updateUserHistory();
void checkHealth();
}

View file

@ -38,7 +38,7 @@ public enum ServerMessagesUtil {
private static final AtomicInteger gamesEnded = new AtomicInteger(0);
private static final AtomicInteger tournamentsStarted = new AtomicInteger(0);
private static final AtomicInteger tournamentsEnded = new AtomicInteger(0);
private static final AtomicInteger lostConnection = new AtomicInteger(0);
private static final AtomicInteger lostConnection = new AtomicInteger(0); // bad connections only
private static final AtomicInteger reconnects = new AtomicInteger(0);
ServerMessagesUtil() {

View file

@ -2,7 +2,7 @@ package mage.server.util;
import mage.server.managers.ConfigSettings;
import mage.server.managers.ThreadExecutor;
import mage.utils.ThreadUtils;
import mage.util.ThreadUtils;
import org.apache.log4j.Logger;
import java.util.concurrent.*;
@ -18,6 +18,7 @@ public class ThreadExecutorImpl implements ThreadExecutor {
private final ExecutorService gameExecutor; // game threads to run long tasks, one per game (example: run game and wait user's feedback)
private final ScheduledExecutorService timeoutExecutor;
private final ScheduledExecutorService timeoutIdleExecutor;
private final ScheduledExecutorService serverHealthExecutor;
/**
* noxx: what the settings below do is setting the ability to keep OS
@ -33,23 +34,27 @@ public class ThreadExecutorImpl implements ThreadExecutor {
public ThreadExecutorImpl(ConfigSettings config) {
//callExecutor = Executors.newCachedThreadPool();
callExecutor = new CachedThreadPoolWithException();
//gameExecutor = Executors.newFixedThreadPool(config.getMaxGameThreads());
gameExecutor = new FixedThreadPoolWithException(config.getMaxGameThreads());
timeoutExecutor = Executors.newScheduledThreadPool(4);
timeoutIdleExecutor = Executors.newScheduledThreadPool(4);
((ThreadPoolExecutor) callExecutor).setKeepAliveTime(60, TimeUnit.SECONDS);
((ThreadPoolExecutor) callExecutor).allowCoreThreadTimeOut(true);
((ThreadPoolExecutor) callExecutor).setThreadFactory(new XMageThreadFactory("CALL"));
//gameExecutor = Executors.newFixedThreadPool(config.getMaxGameThreads());
gameExecutor = new FixedThreadPoolWithException(config.getMaxGameThreads());
((ThreadPoolExecutor) gameExecutor).setKeepAliveTime(60, TimeUnit.SECONDS);
((ThreadPoolExecutor) gameExecutor).allowCoreThreadTimeOut(true);
((ThreadPoolExecutor) gameExecutor).setThreadFactory(new XMageThreadFactory("GAME"));
timeoutExecutor = Executors.newScheduledThreadPool(4);
((ThreadPoolExecutor) timeoutExecutor).setKeepAliveTime(60, TimeUnit.SECONDS);
((ThreadPoolExecutor) timeoutExecutor).allowCoreThreadTimeOut(true);
((ThreadPoolExecutor) timeoutExecutor).setThreadFactory(new XMageThreadFactory("TIMEOUT"));
timeoutIdleExecutor = Executors.newScheduledThreadPool(4);
((ThreadPoolExecutor) timeoutIdleExecutor).setKeepAliveTime(60, TimeUnit.SECONDS);
((ThreadPoolExecutor) timeoutIdleExecutor).allowCoreThreadTimeOut(true);
((ThreadPoolExecutor) timeoutIdleExecutor).setThreadFactory(new XMageThreadFactory("TIMEOUT_IDLE"));
serverHealthExecutor = Executors.newSingleThreadScheduledExecutor(new XMageThreadFactory("HEALTH"));
}
static class CachedThreadPoolWithException extends ThreadPoolExecutor {
@ -119,6 +124,10 @@ public class ThreadExecutorImpl implements ThreadExecutor {
return timeoutIdleExecutor;
}
@Override
public ScheduledExecutorService getServerHealthExecutor() {
return serverHealthExecutor;
}
}
class XMageThreadFactory implements ThreadFactory {