diff --git a/packages/bot/src/bot/start/initializer.ts b/packages/bot/src/bot/start/initializer.ts index c5adf7368..366852b8e 100644 --- a/packages/bot/src/bot/start/initializer.ts +++ b/packages/bot/src/bot/start/initializer.ts @@ -143,7 +143,12 @@ export class BotInitializer { async shutdown(): Promise { if (this.client) { try { + infoLog({ message: 'Starting graceful bot shutdown...' }) + + this.client.removeAllListeners() await this.client.destroy() + await redisClient.disconnect() + this.client = null this.isInitialized = false this.state = { diff --git a/packages/bot/src/index.spec.ts b/packages/bot/src/index.spec.ts index 4aa2f7190..f385ff572 100644 --- a/packages/bot/src/index.spec.ts +++ b/packages/bot/src/index.spec.ts @@ -13,7 +13,9 @@ const initializeSentryMock = jest.fn() const flushSentryMock = jest.fn<(timeout?: number) => Promise>() const debugLogMock = jest.fn() const errorLogMock = jest.fn() +const infoLogMock = jest.fn() const initializeBotMock = jest.fn<() => Promise>() +const shutdownMock = jest.fn<() => Promise>() const dependencyCheckStartMock = jest.fn() jest.mock('@lucky/shared/config', () => ({ @@ -26,10 +28,12 @@ jest.mock('@lucky/shared/utils', () => ({ flushSentry: (...args: unknown[]) => flushSentryMock(...args), debugLog: (...args: unknown[]) => debugLogMock(...args), errorLog: (...args: unknown[]) => errorLogMock(...args), + infoLog: (...args: unknown[]) => infoLogMock(...args), })) jest.mock('./bot/start', () => ({ initializeBot: (...args: unknown[]) => initializeBotMock(...args), + shutdown: (...args: unknown[]) => shutdownMock(...args), })) jest.mock('./services/DependencyCheckService', () => ({ @@ -56,6 +60,7 @@ describe('bot entrypoint', () => { flushSentryMock.mockResolvedValue(true) ensureEnvironmentMock.mockResolvedValue() initializeBotMock.mockResolvedValue() + shutdownMock.mockResolvedValue() process.exit = jest.fn() as unknown as typeof process.exit }) @@ -105,4 +110,79 @@ describe('bot entrypoint', () => { expect(flushSentryMock).toHaveBeenCalledWith(3000) expect(process.exit).toHaveBeenCalledWith(1) }) + + it('handles SIGTERM signal with graceful shutdown', async () => { + let signalHandlers: Record = {} + const originalOn = process.on.bind(process) + process.on = jest.fn((signal: string, handler: Function) => { + signalHandlers[signal] = handler + return process as any + }) as any + + await import('./index') + + expect(process.on).toHaveBeenCalledWith( + 'SIGTERM', + expect.any(Function), + ) + expect(process.on).toHaveBeenCalledWith( + 'SIGINT', + expect.any(Function), + ) + + await signalHandlers['SIGTERM']() + + expect(infoLogMock).toHaveBeenCalledWith({ + message: 'Received SIGTERM, initiating graceful shutdown...', + }) + expect(shutdownMock).toHaveBeenCalled() + expect(flushSentryMock).toHaveBeenCalledWith(3000) + expect(process.exit).toHaveBeenCalledWith(0) + + process.on = originalOn + }) + + it('handles SIGINT signal with graceful shutdown', async () => { + let signalHandlers: Record = {} + const originalOn = process.on.bind(process) + process.on = jest.fn((signal: string, handler: Function) => { + signalHandlers[signal] = handler + return process as any + }) as any + + await import('./index') + + await signalHandlers['SIGINT']() + + expect(infoLogMock).toHaveBeenCalledWith({ + message: 'Received SIGINT, initiating graceful shutdown...', + }) + expect(shutdownMock).toHaveBeenCalled() + expect(flushSentryMock).toHaveBeenCalledWith(3000) + expect(process.exit).toHaveBeenCalledWith(0) + + process.on = originalOn + }) + + it('exits with error code if signal handler fails', async () => { + const shutdownError = new Error('shutdown failed') + shutdownMock.mockRejectedValue(shutdownError) + let signalHandlers: Record = {} + const originalOn = process.on.bind(process) + process.on = jest.fn((signal: string, handler: Function) => { + signalHandlers[signal] = handler + return process as any + }) as any + + await import('./index') + await signalHandlers['SIGTERM']() + + expect(errorLogMock).toHaveBeenCalledWith({ + message: 'Error during SIGTERM shutdown:', + error: shutdownError, + }) + expect(process.exit).toHaveBeenCalledWith(1) + + process.on = originalOn + }) }) diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts index 987e21d27..be63bbbcb 100644 --- a/packages/bot/src/index.ts +++ b/packages/bot/src/index.ts @@ -1,10 +1,27 @@ import { ensureEnvironment } from '@lucky/shared/config' import { setupErrorHandlers } from '@lucky/shared/utils' import { flushSentry, initializeSentry } from '@lucky/shared/utils' -import { initializeBot } from './bot/start' -import { debugLog, errorLog } from '@lucky/shared/utils' +import { initializeBot, shutdown } from './bot/start' +import { debugLog, errorLog, infoLog } from '@lucky/shared/utils' import { dependencyCheckService } from './services/DependencyCheckService' +function setupSignalHandlers(): void { + const signals = ['SIGTERM', 'SIGINT'] + signals.forEach((signal) => { + process.on(signal, async () => { + infoLog({ message: `Received ${signal}, initiating graceful shutdown...` }) + try { + await shutdown() + await flushSentry(3000) + process.exit(0) + } catch (error) { + errorLog({ message: `Error during ${signal} shutdown:`, error }) + process.exit(1) + } + }) + }) +} + async function main(): Promise { await ensureEnvironment() @@ -27,6 +44,8 @@ async function main(): Promise { debugLog({ message: `Starting bot in environment: ${process.env.NODE_ENV ?? 'default'}`, }) + + setupSignalHandlers() await initializeBot() }