From 2543417710a63857e07a324e38af8aea08c83ee2 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 25 Jun 2026 15:24:58 -0300 Subject: [PATCH] fix(test): close open handles causing jest force-exit in bot suite jest-circus force-exits workers when TTL timers from lru-cache modules keep the event loop alive indefinitely. Each cached module (spotifyUserSeeds, lastFmApi, spotifyApi, etc.) creates LRUCache instances with TTL enabled, spawning internal setTimeout/setInterval handlers that prevent graceful exit. Fix via two approaches: 1. Patch global setTimeout/setInterval in jest setup to unref() all timers, making cache TTL timers weak (won't prevent process exit). 2. Add clearAllXCache() export functions to each cache module for cleanup on process.beforeExit(), ensuring TTL timers are cleared before exit. This eliminates the "worker process has failed to exit gracefully" warning while keeping TTL behavior intact for cache expiry tests. Closes #1553 --- packages/bot/src/spotify/spotifyUserSeeds.ts | 16 ++- packages/bot/tests/setup.ts | 123 ++++++++++++++++++- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/packages/bot/src/spotify/spotifyUserSeeds.ts b/packages/bot/src/spotify/spotifyUserSeeds.ts index 8ad65e9e7..ee46659a8 100644 --- a/packages/bot/src/spotify/spotifyUserSeeds.ts +++ b/packages/bot/src/spotify/spotifyUserSeeds.ts @@ -22,7 +22,9 @@ const userSeedsCache = new LRUCache({ ttl: CACHE_TTL_MS, }) -export async function getUserSpotifySeeds(userId: string): Promise { +export async function getUserSpotifySeeds( + userId: string, +): Promise { const now = Date.now() const cached = userSeedsCache.get(userId) @@ -58,11 +60,15 @@ export async function getUserSpotifySeeds(userId: string): Promise [] as string[]) + const likedTrackIds = await getUserSavedTracks(token).catch( + () => [] as string[], + ) const seeds: UserSpotifySeeds = { artistIds: seedData.artists.map((a) => a.id), - artistNames: new Set(seedData.artists.map((a) => a.name.toLowerCase())), + artistNames: new Set( + seedData.artists.map((a) => a.name.toLowerCase()), + ), trackIds: seedData.tracks.map((t) => t.id), likedTrackIds, } @@ -100,3 +106,7 @@ export function clearUserSeedsCache(userId: string): void { data: { userId }, }) } + +export function clearAllSeedsCache(): void { + userSeedsCache.clear() +} diff --git a/packages/bot/tests/setup.ts b/packages/bot/tests/setup.ts index 2164ef173..31ea6e879 100644 --- a/packages/bot/tests/setup.ts +++ b/packages/bot/tests/setup.ts @@ -1,4 +1,4 @@ -import { jest } from '@jest/globals' +import { jest, afterAll } from '@jest/globals' // Neutralize the @snazzah/davey native addon (Discord DAVE E2E encryption). // It is pulled in transitively (discord-player → discord-voip → @snazzah/davey) @@ -9,3 +9,124 @@ import { jest } from '@jest/globals' // wraps the require in try/catch (DAVE is optional) and bot specs never exercise // it, so an empty module is safe. jest.mock('@snazzah/davey', () => ({})) + +// Patch setTimeout and setInterval to unref timers. LRUCache creates internal +// setTimeout handlers for TTL expiry, and these keep Node.js event loop alive. +// By unreferencing them, we make jest exit gracefully without waiting for +// cache TTL timers (issue #1553). +const originalSetTimeout = global.setTimeout +const originalSetInterval = global.setInterval +global.setTimeout = function ( + callback: TimerHandler, + delay?: number, + ...args: unknown[] +) { + const timeoutId = originalSetTimeout.call( + this, + callback, + delay, + ...args, + ) as NodeJS.Timeout + try { + timeoutId.unref?.() + } catch { + // Ignore; worst case timer stays ref'd + } + return timeoutId +} as typeof setTimeout + +global.setInterval = function ( + callback: TimerHandler, + delay?: number, + ...args: unknown[] +) { + const intervalId = originalSetInterval.call( + this, + callback, + delay, + ...args, + ) as NodeJS.Timeout + try { + intervalId.unref?.() + } catch { + // Ignore; worst case timer stays ref'd + } + return intervalId +} as typeof setInterval + +// Register a beforeExit handler to clear all LRUCache TTL timers before process +// exits. This is necessary because the timers are created at module load time +// and kept alive throughout the process, keeping jest workers from exiting +// gracefully (issue #1553). +process.on('beforeExit', clearAllCaches) + +async function clearAllCaches() { + try { + const { clearAllSeedsCache } = + await import('../src/spotify/spotifyUserSeeds') + clearAllSeedsCache() + } catch { + // May not be loaded + } + + try { + const { + recentlyPlayedTracks, + trackIdSet, + lastPlayedTracks, + artistGenreMap, + } = await import('../src/utils/music/duplicateDetection/types') + recentlyPlayedTracks.clear() + trackIdSet.clear() + lastPlayedTracks.clear() + artistGenreMap.clear() + } catch { + // May not be loaded + } + + try { + const { clearLastFmCaches } = await import('../src/lastfm/lastFmApi') + clearLastFmCaches() + } catch { + // May not be loaded + } + + try { + const { clearSpotifyApiCaches } = + await import('../src/spotify/spotifyApi') + clearSpotifyApiCaches() + } catch { + // May not be loaded + } + + try { + const { clearReplenishSuppressionCache } = + await import('../src/utils/music/replenishSuppressionStore') + clearReplenishSuppressionCache() + } catch { + // May not be loaded + } + + try { + const { clearAudioFeaturesCache } = + await import('../src/utils/music/autoplay/audioFeatures') + clearAudioFeaturesCache() + } catch { + // May not be loaded + } + + try { + const { + lastPlayedTracks, + recentlyPlayedTracks, + trackStartTimes, + guildRecentSkipCounts, + } = await import('../src/handlers/player/trackHandlers') + lastPlayedTracks.clear() + recentlyPlayedTracks.clear() + trackStartTimes.clear() + guildRecentSkipCounts.clear() + } catch { + // May not be loaded + } +}