Refactor timer system to run on main thread instead of child processes

Co-authored-by: AlecM33 <24642328+AlecM33@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-01-24 03:37:43 +00:00
parent 1432cd886d
commit ae7cf5cbeb
4 changed files with 252 additions and 119 deletions

View File

@@ -164,7 +164,7 @@ const Events = [
vars.gameManager.deal(game);
if (game.hasTimer) {
game.timerParams.paused = true;
await vars.timerManager.runTimer(game, vars.gameManager.namespace, vars.eventManager, vars.gameManager);
await vars.gameManager.runTimer(game);
}
}
},
@@ -297,12 +297,10 @@ const Events = [
id: EVENT_IDS.RESTART_GAME,
stateChange: async (game, socketArgs, vars) => {
if (vars.instanceId !== vars.senderInstanceId
&& vars.timerManager.timerThreads[game.accessCode]
&& vars.gameManager.timers[game.accessCode]
) {
if (!vars.timerManager.timerThreads[game.accessCode].killed) {
vars.timerManager.timerThreads[game.accessCode].kill();
}
delete vars.timerManager.timerThreads[game.accessCode];
vars.gameManager.timers[game.accessCode].stopTimer();
delete vars.gameManager.timers[game.accessCode];
}
},
communicate: async (game, socketArgs, vars) => {
@@ -316,24 +314,65 @@ const Events = [
id: EVENT_IDS.TIMER_EVENT,
stateChange: async (game, socketArgs, vars) => {},
communicate: async (game, socketArgs, vars) => {
const thread = vars.timerManager.timerThreads[game.accessCode];
if (thread) {
if (!thread.killed && thread.exitCode === null) {
thread.send({
command: vars.timerEventSubtype,
accessCode: game.accessCode,
socketId: vars.requestingSocketId,
logLevel: vars.logger.logLevel
});
} else {
const socket = vars.gameManager.namespace.sockets.get(vars.requestingSocketId);
if (socket) {
vars.gameManager.namespace.to(socket.id).emit(
GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
game.timerParams.timeRemaining,
game.timerParams.paused
);
}
const timer = vars.gameManager.timers[game.accessCode];
if (timer) {
// Timer is running on this instance, handle the request directly
switch (vars.timerEventSubtype) {
case GAME_PROCESS_COMMANDS.PAUSE_TIMER:
const pauseTimeRemaining = await vars.gameManager.pauseTimer(game);
if (pauseTimeRemaining !== null) {
// Trigger PAUSE_TIMER event to update state and notify clients
await vars.eventManager.handleEventById(
EVENT_IDS.PAUSE_TIMER,
null,
game,
null,
game.accessCode,
{ timeRemaining: pauseTimeRemaining },
null,
false
);
await vars.gameManager.refreshGame(game);
await vars.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.PAUSE_TIMER,
vars.instanceId,
JSON.stringify({ timeRemaining: pauseTimeRemaining })
)
);
}
break;
case GAME_PROCESS_COMMANDS.RESUME_TIMER:
const resumeTimeRemaining = await vars.gameManager.resumeTimer(game);
if (resumeTimeRemaining !== null) {
// Trigger RESUME_TIMER event to update state and notify clients
await vars.eventManager.handleEventById(
EVENT_IDS.RESUME_TIMER,
null,
game,
null,
game.accessCode,
{ timeRemaining: resumeTimeRemaining },
null,
false
);
await vars.gameManager.refreshGame(game);
await vars.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.RESUME_TIMER,
vars.instanceId,
JSON.stringify({ timeRemaining: resumeTimeRemaining })
)
);
}
break;
case GAME_PROCESS_COMMANDS.GET_TIME_REMAINING:
await vars.gameManager.getTimeRemaining(game, vars.requestingSocketId);
break;
}
} else { // we need to consult another container for the timer data
await vars.eventManager.publisher?.publish(
@@ -350,34 +389,83 @@ const Events = [
},
{
/* This event is a request from another instance to consult its timer data. In response
* to this event, this instance will check if it is home to a particular timer thread. */
* to this event, this instance will check if it is home to a particular timer. */
id: EVENT_IDS.SOURCE_TIMER_EVENT,
stateChange: async (game, socketArgs, vars) => {},
communicate: async (game, socketArgs, vars) => {
const thread = vars.timerManager.timerThreads[game.accessCode];
if (thread) {
if (!thread.killed && thread.exitCode === null) {
thread.send({
command: socketArgs.timerEventSubtype,
accessCode: game.accessCode,
socketId: socketArgs.socketId,
logLevel: vars.logger.logLevel
});
} else {
await vars.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
socketArgs.timerEventSubtype,
vars.instanceId,
JSON.stringify({
socketId: socketArgs.socketId,
timeRemaining: game.timerParams.timeRemaining,
paused: game.timerParams.paused
})
)
);
const timer = vars.gameManager.timers[game.accessCode];
if (timer) {
// Timer is running on this instance, handle the request
switch (socketArgs.timerEventSubtype) {
case GAME_PROCESS_COMMANDS.PAUSE_TIMER:
const pauseTimeRemaining = await vars.gameManager.pauseTimer(game);
if (pauseTimeRemaining !== null) {
await vars.eventManager.handleEventById(
EVENT_IDS.PAUSE_TIMER,
null,
game,
null,
game.accessCode,
{ timeRemaining: pauseTimeRemaining },
null,
false
);
await vars.gameManager.refreshGame(game);
await vars.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.PAUSE_TIMER,
vars.instanceId,
JSON.stringify({ timeRemaining: pauseTimeRemaining })
)
);
}
break;
case GAME_PROCESS_COMMANDS.RESUME_TIMER:
const resumeTimeRemaining = await vars.gameManager.resumeTimer(game);
if (resumeTimeRemaining !== null) {
await vars.eventManager.handleEventById(
EVENT_IDS.RESUME_TIMER,
null,
game,
null,
game.accessCode,
{ timeRemaining: resumeTimeRemaining },
null,
false
);
await vars.gameManager.refreshGame(game);
await vars.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.RESUME_TIMER,
vars.instanceId,
JSON.stringify({ timeRemaining: resumeTimeRemaining })
)
);
}
break;
case GAME_PROCESS_COMMANDS.GET_TIME_REMAINING:
await vars.gameManager.getTimeRemaining(game, socketArgs.socketId);
break;
}
} else {
// Timer not running here, publish stored timer state
await vars.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
vars.eventManager.createMessageToPublish(
game.accessCode,
socketArgs.timerEventSubtype,
vars.instanceId,
JSON.stringify({
socketId: socketArgs.socketId,
timeRemaining: game.timerParams.timeRemaining,
paused: game.timerParams.paused
})
)
);
}
}
},

View File

@@ -12,6 +12,7 @@ const Person = require('../../model/Person');
const GameStateCurator = require('../GameStateCurator');
const UsernameGenerator = require('../UsernameGenerator');
const GameCreationRequest = require('../../model/GameCreationRequest');
const ServerTimer = require('../ServerTimer');
class GameManager {
constructor (logger, environment, instanceId) {
@@ -25,16 +26,16 @@ class GameManager {
this.eventManager = null;
this.namespace = null;
this.instanceId = instanceId;
this.timers = {}; // Map of accessCode -> ServerTimer instance
GameManager.instance = this;
}
getActiveGame = async (accessCode) => {
const r = await this.eventManager.publisher.get(accessCode);
if (r === null && this.timerManager.timerThreads[accessCode]) {
if (!this.timerManager.timerThreads[accessCode].killed) {
this.timerManager.timerThreads[accessCode].kill();
}
delete this.timerManager.timerThreads[accessCode];
if (r === null && this.timers[accessCode]) {
// Clean up orphaned timer
this.timers[accessCode].stopTimer();
delete this.timers[accessCode];
}
let activeGame;
if (r !== null) {
@@ -108,43 +109,87 @@ class GameManager {
});
};
pauseTimer = async (game, logger) => {
const thread = this.timerManager.timerThreads[game.accessCode];
if (thread && !thread.killed) {
this.logger.debug('Timer thread found for game ' + game.accessCode);
thread.send({
command: GAME_PROCESS_COMMANDS.PAUSE_TIMER,
accessCode: game.accessCode,
logLevel: this.logger.logLevel
});
}
runTimer = async (game) => {
this.logger.debug('running timer for game ' + game.accessCode);
const timer = new ServerTimer(
game.timerParams.hours,
game.timerParams.minutes,
PRIMITIVES.CLOCK_TICK_INTERVAL_MILLIS,
this.logger
);
this.timers[game.accessCode] = timer;
// Start timer in paused state initially (pausedInitially = true)
timer.runTimer(true).then(async () => {
this.logger.debug('Timer finished for ' + game.accessCode);
// Trigger END_TIMER event
game = await this.getActiveGame(game.accessCode);
if (game) {
await this.eventManager.handleEventById(
EVENT_IDS.END_TIMER,
null,
game,
null,
game.accessCode,
{},
null,
false
);
await this.refreshGame(game);
await this.eventManager.publisher.publish(
REDIS_CHANNELS.ACTIVE_GAME_STREAM,
this.eventManager.createMessageToPublish(
game.accessCode,
EVENT_IDS.END_TIMER,
this.instanceId,
'{}'
)
);
}
// Clean up timer instance
delete this.timers[game.accessCode];
});
game.startTime = new Date().toJSON();
};
resumeTimer = async (game, logger) => {
const thread = this.timerManager.timerThreads[game.accessCode];
if (thread && !thread.killed) {
this.logger.debug('Timer thread found for game ' + game.accessCode);
thread.send({
command: GAME_PROCESS_COMMANDS.RESUME_TIMER,
accessCode: game.accessCode,
logLevel: this.logger.logLevel
});
pauseTimer = async (game) => {
const timer = this.timers[game.accessCode];
if (timer) {
this.logger.debug('Timer found for game ' + game.accessCode);
timer.stopTimer();
return timer.currentTimeInMillis;
}
return null;
};
resumeTimer = async (game) => {
const timer = this.timers[game.accessCode];
if (timer) {
this.logger.debug('Timer found for game ' + game.accessCode);
timer.resumeTimer();
return timer.currentTimeInMillis;
}
return null;
};
getTimeRemaining = async (game, socketId) => {
if (socketId) {
const thread = this.timerManager.timerThreads[game.accessCode];
if (thread && (!thread.killed && thread.exitCode === null)) {
thread.send({
command: GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
accessCode: game.accessCode,
socketId: socketId,
logLevel: this.logger.logLevel
});
} else if (thread) {
if (game.timerParams && game.timerParams.timeRemaining === 0) {
this.namespace.to(socketId).emit(GAME_PROCESS_COMMANDS.GET_TIME_REMAINING, game.timerParams.timeRemaining, game.timerParams.paused);
const timer = this.timers[game.accessCode];
if (timer) {
// Timer is running on this instance, emit directly
this.namespace.to(socketId).emit(
GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
timer.currentTimeInMillis,
game.timerParams.paused
);
} else {
// Timer not running on this instance, return stored value
if (game.timerParams) {
this.namespace.to(socketId).emit(
GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
game.timerParams.timeRemaining,
game.timerParams.paused
);
}
}
}
@@ -248,15 +293,12 @@ class GameManager {
}
restartGame = async (game, namespace) => {
// kill any outstanding timer threads
const subProcess = this.timerManager.timerThreads[game.accessCode];
if (subProcess) {
if (!subProcess.killed) {
this.logger.info('Killing timer process ' + subProcess.pid + ' for: ' + game.accessCode);
this.timerManager.timerThreads[game.accessCode].kill();
}
this.logger.debug('Deleting reference to subprocess ' + subProcess.pid);
delete this.timerManager.timerThreads[game.accessCode];
// stop any outstanding timers
const timer = this.timers[game.accessCode];
if (timer) {
this.logger.info('Stopping timer for: ' + game.accessCode);
timer.stopTimer();
delete this.timers[game.accessCode];
}
for (let i = 0; i < game.people.length; i ++) {

View File

@@ -1,6 +1,6 @@
// TODO: clean up these deep relative paths? jsconfig.json is not working...
const Game = require('../../../../server/model/Game');
const { ENVIRONMENTS, EVENT_IDS, USER_TYPES, STATUS } = require('../../../../server/config/globals.js');
const { ENVIRONMENTS, EVENT_IDS, USER_TYPES, STATUS, GAME_PROCESS_COMMANDS } = require('../../../../server/config/globals.js');
const GameManager = require('../../../../server/modules/singletons/GameManager.js');
const TimerManager = require('../../../../server/modules/singletons/TimerManager.js');
const EventManager = require('../../../../server/modules/singletons/EventManager.js');
@@ -272,12 +272,12 @@ describe('Events', () => {
game.isStartable = true;
game.hasTimer = true;
game.timerParams = {};
spyOn(timerManager, 'runTimer').and.callFake((a, b) => {});
spyOn(gameManager, 'runTimer').and.callFake(() => {});
await Events.find((e) => e.id === EVENT_IDS.START_GAME)
.stateChange(game, { id: 'b', assigned: true }, { gameManager: gameManager, timerManager: timerManager });
expect(game.status).toEqual(STATUS.IN_PROGRESS);
expect(game.timerParams.paused).toEqual(true);
expect(timerManager.runTimer).toHaveBeenCalled();
expect(gameManager.runTimer).toHaveBeenCalled();
});
});
describe('communicate', () => {
@@ -530,22 +530,22 @@ describe('Events', () => {
describe(EVENT_IDS.RESTART_GAME, () => {
describe('stateChange', () => {
it('should kill any alive timer thread if the instance is home to it', async () => {
const mockThread = { kill: () => {}, killed: false };
timerManager.timerThreads = { ABCD: mockThread };
spyOn(timerManager.timerThreads.ABCD, 'kill').and.callThrough();
const mockTimer = { stopTimer: () => {} };
gameManager.timers = { ABCD: mockTimer };
spyOn(gameManager.timers.ABCD, 'stopTimer').and.callThrough();
await Events.find((e) => e.id === EVENT_IDS.RESTART_GAME)
.stateChange(game, { personId: 'b' }, { gameManager: gameManager, timerManager: timerManager, instanceId: '111', senderInstanceId: '222' });
expect(mockThread.kill).toHaveBeenCalled();
expect(Object.keys(timerManager.timerThreads).length).toEqual(0);
expect(mockTimer.stopTimer).toHaveBeenCalled();
expect(Object.keys(gameManager.timers).length).toEqual(0);
});
it('should not kill the timer thread if the instance sent the event', async () => {
const mockThread = { kill: () => {}, killed: false };
timerManager.timerThreads = { ABCD: mockThread };
spyOn(timerManager.timerThreads.ABCD, 'kill').and.callThrough();
const mockTimer = { stopTimer: () => {} };
gameManager.timers = { ABCD: mockTimer };
spyOn(gameManager.timers.ABCD, 'stopTimer').and.callThrough();
await Events.find((e) => e.id === EVENT_IDS.RESTART_GAME)
.stateChange(game, { personId: 'b' }, { gameManager: gameManager, timerManager: timerManager, instanceId: '111', senderInstanceId: '111' });
expect(mockThread.kill).not.toHaveBeenCalled();
expect(Object.keys(timerManager.timerThreads).length).toEqual(1);
expect(mockTimer.stopTimer).not.toHaveBeenCalled();
expect(Object.keys(gameManager.timers).length).toEqual(1);
});
});
describe('communicate', () => {
@@ -571,28 +571,30 @@ describe('Events', () => {
describe('communicate', () => {
it('should publish an event to source timer data if the timer thread is not found', async () => {
await Events.find((e) => e.id === EVENT_IDS.TIMER_EVENT)
.communicate(game, {}, { gameManager: gameManager, timerManager: timerManager, eventManager: eventManager });
.communicate(game, {}, {
gameManager: gameManager,
timerManager: timerManager,
eventManager: eventManager,
instanceId: 'test',
timerEventSubtype: GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
requestingSocketId: '2'
});
expect(eventManager.publisher.publish).toHaveBeenCalled();
});
it('should send a message to the thread if it is found', async () => {
const mockThread = { exitCode: null, kill: () => {}, send: (...args) => {}, killed: false };
timerManager.timerThreads = { ABCD: mockThread };
spyOn(timerManager.timerThreads.ABCD, 'send').and.callThrough();
const mockTimer = { currentTimeInMillis: 5000 };
gameManager.timers = { ABCD: mockTimer };
spyOn(gameManager, 'getTimeRemaining').and.returnValue(Promise.resolve());
await Events.find((e) => e.id === EVENT_IDS.TIMER_EVENT)
.communicate(game, {}, {
gameManager: gameManager,
timerManager: timerManager,
eventManager: eventManager,
timerEventSubtype: EVENT_IDS.GET_TIME_REMAINING,
timerEventSubtype: GAME_PROCESS_COMMANDS.GET_TIME_REMAINING,
requestingSocketId: '2',
logger: { logLevel: 'trace' }
});
expect(mockThread.send).toHaveBeenCalledWith({
command: EVENT_IDS.GET_TIME_REMAINING,
accessCode: 'ABCD',
socketId: '2',
logLevel: 'trace'
});
expect(gameManager.getTimeRemaining).toHaveBeenCalledWith(game, '2');
});
});
});

View File

@@ -91,16 +91,17 @@ describe('GameManager', () => {
it('should reset all relevant game parameters, including when the game has a timer', async () => {
game.timerParams = { hours: 2, minutes: 2, paused: false };
game.hasTimer = true;
timerManager.timerThreads = { ABCD: { kill: () => {} } };
const mockTimer = { stopTimer: () => {} };
gameManager.timers = { ABCD: mockTimer };
game.status = STATUS.ENDED;
const threadKillSpy = spyOn(timerManager.timerThreads.ABCD, 'kill');
const stopTimerSpy = spyOn(gameManager.timers.ABCD, 'stopTimer');
const emitSpy = spyOn(namespace.in(), 'emit');
await gameManager.restartGame(game, namespace);
expect(game.status).toEqual(STATUS.LOBBY);
expect(threadKillSpy).toHaveBeenCalled();
expect(stopTimerSpy).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(globals.EVENT_IDS.RESTART_GAME);
});