Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/bot/src/bot/start/initializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,12 @@ export class BotInitializer {
async shutdown(): Promise<void> {
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 = {
Expand Down
80 changes: 80 additions & 0 deletions packages/bot/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ const initializeSentryMock = jest.fn()
const flushSentryMock = jest.fn<(timeout?: number) => Promise<boolean>>()
const debugLogMock = jest.fn()
const errorLogMock = jest.fn()
const infoLogMock = jest.fn()
const initializeBotMock = jest.fn<() => Promise<void>>()
const shutdownMock = jest.fn<() => Promise<void>>()
const dependencyCheckStartMock = jest.fn()

jest.mock('@lucky/shared/config', () => ({
Expand All @@ -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', () => ({
Expand All @@ -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
})

Expand Down Expand Up @@ -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<string, Function> = {}
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<string, Function> = {}
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<string, Function> = {}
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
})
})
23 changes: 21 additions & 2 deletions packages/bot/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await ensureEnvironment()

Expand All @@ -27,6 +44,8 @@ async function main(): Promise<void> {
debugLog({
message: `Starting bot in environment: ${process.env.NODE_ENV ?? 'default'}`,
})

setupSignalHandlers()
await initializeBot()
}

Expand Down
Loading