diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 3b3ccd7d..f092019f 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -22,5 +22,53 @@ export async function register() { } catch (err) { console.error('Error reconciling sessions:', err); } + + // Register graceful shutdown handler + registerShutdownHandler(); } } + +/** + * Register signal handlers for graceful shutdown. + * Stops active Claude queries and disconnects Prisma so the process can exit cleanly. + */ +function registerShutdownHandler() { + let shuttingDown = false; + + const shutdown = async (signal: string) => { + if (shuttingDown) { + console.log(`Received ${signal} again, forcing exit`); + process.exit(1); + } + shuttingDown = true; + console.log(`Received ${signal}, shutting down gracefully...`); + + // Force exit after 10s if graceful shutdown hangs + // (important for SIGTERM from systemd where there's no second signal) + setTimeout(() => { + console.error('Graceful shutdown timed out, forcing exit'); + process.exit(1); + }, 10_000).unref(); + + try { + // Stop all active Claude queries + const { stopAllSessions } = await import('@/server/services/claude-runner'); + await stopAllSessions(); + } catch (err) { + console.error('Error stopping sessions during shutdown:', err); + } + + try { + // Disconnect Prisma + const { prisma } = await import('@/lib/prisma'); + await prisma.$disconnect(); + } catch (err) { + console.error('Error disconnecting Prisma during shutdown:', err); + } + + process.exit(0); + }; + + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); +} diff --git a/src/lib/rate-limiter.ts b/src/lib/rate-limiter.ts index 09f81f8c..6b115c4c 100644 --- a/src/lib/rate-limiter.ts +++ b/src/lib/rate-limiter.ts @@ -212,11 +212,12 @@ export class RateLimiter { export const loginRateLimiter = new RateLimiter(); // Start cleanup interval (every hour) +// Use .unref() so this timer doesn't prevent graceful shutdown if (typeof setInterval !== 'undefined') { setInterval( () => { loginRateLimiter.cleanup(); }, 60 * 60 * 1000 - ); + ).unref(); } diff --git a/src/server/services/claude-runner.ts b/src/server/services/claude-runner.ts index 71b99183..0a4b4431 100644 --- a/src/server/services/claude-runner.ts +++ b/src/server/services/claude-runner.ts @@ -635,7 +635,7 @@ export async function markLastMessageAsInterrupted(sessionId: string): Promise { +export function stopSession(sessionId: string): void { const state = sessions.get(sessionId); if (!state) return; @@ -658,6 +658,17 @@ export async function stopSession(sessionId: string): Promise { sessions.delete(sessionId); } +/** + * Stop all active Claude queries. Called during graceful shutdown. + */ +export async function stopAllSessions(): Promise { + const sessionIds = [...sessions.keys()]; + if (sessionIds.length === 0) return; + + log.info('Stopping all active sessions for shutdown', { count: sessionIds.length }); + await Promise.allSettled(sessionIds.map((id) => stopSession(id))); +} + /** * Mark all running sessions as stopped. * Called on server startup since all in-memory state is lost.