@@ -75,6 +75,7 @@ import { InMemoryPauseInterventionStore } from './services/acp/in-memory-pause-i
7575import { isWindowsShutdownMessage , parseShutdownTimeoutMs } from './shutdown-utils.js' ;
7676import { ServiceContainer } from './container.js' ;
7777import type { AppContext } from './app-context.js' ;
78+ import { TimerRegistry } from './utils/timer-registry.js' ;
7879import { AcpBackend } from './services/acp/backend.js' ;
7980import { AcpSessionService } from './services/acp/session-service.js' ;
8081import { AcpTerminalBridge } from './services/acp/terminal-bridge.js' ;
@@ -145,6 +146,8 @@ declare module 'fastify' {
145146// Module-level const values are still initialized here.
146147const channels = new ChannelManager ( ) ;
147148const eventBus = new SessionEventBus ( ) ;
149+ // Issue #4248: Centralized timer registry for clean shutdown
150+ const timers = new TimerRegistry ( ) ;
148151// Issue #4116: Debounce Set for session approval callbacks (prevents duplicate notifications from rapid Telegram clicks).
149152const recentApprovalActions = new Set < string > ( ) ;
150153
@@ -771,8 +774,8 @@ function setupConfigWatcher(ctx: AppContext): void {
771774 ctx . configWatcher = watch ( configPath , ( _eventType ) => {
772775 // Accept all event types — editors emit rename (atomic save), change, or undefined.
773776 // Debounce: FS events can fire multiple times for one save
774- if ( ctx . configReloadTimer ) clearTimeout ( ctx . configReloadTimer ) ;
775- ctx . configReloadTimer = setTimeout ( ( ) => {
777+ if ( ctx . configReloadTimer ) timers . clearTimeout ( ctx . configReloadTimer ) ;
778+ ctx . configReloadTimer = timers . setTimeout ( ( ) => {
776779 void handleConfigReload ( 'file-change' , ctx ) ;
777780 } , 300 ) ;
778781 } ) ;
@@ -1277,24 +1280,25 @@ registerBudgetRoutes(app, { auth: ctx.auth, budgetStore, budgetEvaluator });
12771280 registerOpenApiRoute ( app ) ;
12781281
12791282 // Issue #361: Store interval refs so graceful shutdown can clear them
1280- const reaperInterval = setInterval ( ( ) => reapStaleSessions ( ctx . config . maxSessionAgeMs , ctx ) , ctx . config . reaperIntervalMs ) ;
1281- const zombieReaperInterval = setInterval ( ( ) => reapZombieSessions ( ctx ) , ZOMBIE_REAP_INTERVAL_MS ) ;
1282- const metricsSaveInterval = setInterval ( ( ) => { void ctx . metrics . save ( ) ; } , 5 * 60 * 1000 ) ;
1283+ timers . setInterval ( ( ) => reapStaleSessions ( ctx . config . maxSessionAgeMs , ctx ) , ctx . config . reaperIntervalMs ) ;
1284+ timers . setInterval ( ( ) => reapZombieSessions ( ctx ) , ZOMBIE_REAP_INTERVAL_MS ) ;
1285+ timers . setInterval ( ( ) => { void ctx . metrics . save ( ) ; } , 5 * 60 * 1000 ) ;
12831286 // Issue #3310: Periodically persist metering data.
1284- const meteringSaveInterval = setInterval ( ( ) => { void metering . save ( ) ; } , 5 * 60 * 1000 ) ;
1287+ timers . setInterval ( ( ) => { void metering . save ( ) ; } , 5 * 60 * 1000 ) ;
12851288 // #357: Prune stale IP rate-limit entries every minute
1286- const ipPruneInterval = setInterval ( pruneIpRateLimits , 60_000 ) ;
1289+ timers . setInterval ( pruneIpRateLimits , 60_000 ) ;
12871290 // #632: Prune stale auth failure rate-limit buckets every minute
1288- const authFailPruneInterval = setInterval ( pruneAuthFailLimits , 60_000 ) ;
1291+ timers . setInterval ( pruneAuthFailLimits , 60_000 ) ;
12891292 // #398: Sweep stale API key rate limit buckets every 5 minutes
1290- const authSweepInterval = setInterval ( ( ) => ctx . auth . sweepStaleRateLimits ( ) , 5 * 60_000 ) ;
1293+ timers . setInterval ( ( ) => ctx . auth . sweepStaleRateLimits ( ) , 5 * 60_000 ) ;
12911294 // #2452: Sweep expired quota usage entries every 5 minutes to prevent unbounded growth
12921295 const quotaSweepInterval = setInterval ( ( ) => routeCtx . quotas . sweep ( ) , 5 * 60_000 ) ;
12931296 // Issue #4004: Start orphan action sweeper
12941297ctx . actionSweeper ?. start ( ) ;
12951298 // Issue #4195: Start budget evaluation timer
12961299 budgetTimer . start ( ) ;
12971300 // #3227: Prune interval from StaticRateLimiter — assigned after registerDashboardStatic()
1301+ // Issue #4248: staticPruneInterval tracked via timers.track() after registration
12981302 let staticPruneInterval : ReturnType < typeof setInterval > | null = null ;
12991303 let pidFilePath = '' ;
13001304
@@ -1360,20 +1364,13 @@ ctx.actionSweeper?.start();
13601364 // #1753: Close config file watcher
13611365ctx . configWatcher ?. close ( ) ;
13621366 ctx . configWatcher = null ;
1363- if ( ctx . configReloadTimer ) { clearTimeout ( ctx . configReloadTimer ) ; ctx . configReloadTimer = null ; }
1364- clearInterval ( reaperInterval ) ;
1365- clearInterval ( zombieReaperInterval ) ;
1366- clearInterval ( metricsSaveInterval ) ;
1367- clearInterval ( meteringSaveInterval ) ;
1368- clearInterval ( ipPruneInterval ) ;
1369- clearInterval ( authFailPruneInterval ) ;
1370- clearInterval ( authSweepInterval ) ;
1371- clearInterval ( quotaSweepInterval ) ;
1367+ if ( ctx . configReloadTimer ) { timers . clearTimeout ( ctx . configReloadTimer ) ; ctx . configReloadTimer = null ; }
1368+ // Issue #4248: Clear all tracked timers via TimerRegistry
1369+ timers . clearAll ( ) ;
13721370 // Issue #4004: Stop orphan action sweeper
13731371ctx . actionSweeper ?. stop ( ) ;
13741372 // Issue #4195: Stop budget evaluation timer
13751373 budgetTimer . stop ( ) ;
1376- if ( staticPruneInterval ) clearInterval ( staticPruneInterval ) ;
13771374 rateLimiter . dispose ( ) ;
13781375
13791376 // 3. Close file watchers, pipelines, and reaper
@@ -1570,6 +1567,7 @@ ctx.auditLogger?.flush() ?? Promise.resolve(),
15701567 // #3154: Dashboard static serving extracted to plugins/dashboard-static.ts
15711568 // #3227: Capture prune interval handle for cleanup on shutdown
15721569staticPruneInterval = await registerDashboardStatic ( app , { enabled : ctx . config . dashboardEnabled !== false } ) ;
1570+ if ( staticPruneInterval ) timers . track ( staticPruneInterval ) ;
15731571 await container . assertHealthy ( ) ;
15741572await listenWithRetry ( app , ctx . config . port , ctx . config . host , ctx . config . stateDir ) ;
15751573pidFilePath = await writePidFile ( ctx . config . stateDir ) ;
0 commit comments