From 7bd110684d0b85844c09971487e808b33c99160b Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 20:39:11 +0000 Subject: [PATCH] fix: track and clear all intervals and event listeners - FightPage.vue: store SSE listener refs in array, removeEventListener on each before close() in disconnectSSE() - FightScene.ts: add cleanupTimers Set with trackedInterval/trackedTimeout helpers; replace key setInterval calls (projectile trails, entrance flames, talking animation, exhaust effects, dimensional shift, hole fade) with tracked versions; clear all on scene destroy() - rate-limit.ts: export cleanupInterval handle for graceful shutdown Co-Authored-By: Claude Opus 4.6 --- frontend/src/game/FightScene.ts | 68 +++++++++++++++++++---------- frontend/src/pages/FightPage.vue | 35 +++++++++------ server/src/middleware/rate-limit.ts | 2 +- 3 files changed, 69 insertions(+), 36 deletions(-) diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index ddb8b39..915870f 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -347,6 +347,23 @@ export async function createFightScene(config: FightSceneConfig) { const W = k.width() const H = k.height() + // Track all intervals/timeouts for cleanup on scene destroy + const cleanupTimers = new Set>() + function trackedTimeout(fn: () => void, ms: number): ReturnType { + const id = setTimeout(() => { cleanupTimers.delete(id); fn() }, ms) + cleanupTimers.add(id) + return id + } + function trackedInterval(fn: () => void, ms: number): ReturnType { + const id = setInterval(fn, ms) + cleanupTimers.add(id) + return id + } + function clearTracked(id: ReturnType) { + clearInterval(id) + clearTimeout(id) + cleanupTimers.delete(id) + } // Scale factor so sprites shrink on small canvases (reference: 500px tall) const SF = Math.min(1, H / 500) const GROUND_Y = H * 0.78 @@ -392,10 +409,10 @@ export async function createFightScene(config: FightSceneConfig) { k.z(9), // behind fighters ]) // Fade out after a bit - setTimeout(() => { - const fadeInterval = setInterval(() => { + trackedTimeout(() => { + const fadeInterval = trackedInterval(() => { hole.opacity -= 0.02 - if (hole.opacity <= 0) { hole.destroy(); clearInterval(fadeInterval) } + if (hole.opacity <= 0) { hole.destroy(); clearTracked(fadeInterval) } }, 50) }, 1500) } @@ -403,8 +420,8 @@ export async function createFightScene(config: FightSceneConfig) { function spawnExhaust(x: number, y: number, duration: number) { const endTime = performance.now() + duration * 1000 - const interval = setInterval(() => { - if (performance.now() > endTime) { clearInterval(interval); return } + const interval = trackedInterval(() => { + if (performance.now() > endTime) { clearTracked(interval); return } for (let i = 0; i < 3; i++) { const p = k.add([ k.circle(3 + Math.random() * 5), @@ -434,7 +451,7 @@ export async function createFightScene(config: FightSceneConfig) { k.z(15), ]) // Trail - const trail = setInterval(() => { + const trail = trackedInterval(() => { const t = k.add([ k.circle(size * 0.6), k.pos(proj.pos.x, proj.pos.y), @@ -455,7 +472,7 @@ export async function createFightScene(config: FightSceneConfig) { proj.pos.x = fromX + dx * t proj.pos.y = fromY + dy * t }, k.easings.linear).then(() => { - clearInterval(trail) + clearTracked(trail) proj.destroy() spawnSparks(toX, toY, 8, color) resolve() @@ -569,12 +586,12 @@ export async function createFightScene(config: FightSceneConfig) { k.rect(W, H), k.pos(0, 0), k.color(safeColor(k,colors[0])), k.opacity(0.08), k.z(1), ]) - const interval = setInterval(() => { + const interval = trackedInterval(() => { idx = (idx + 1) % colors.length overlay.color = safeColor(k,colors[idx]) overlay.opacity = 0.05 + Math.random() * 0.06 }, 80) - setTimeout(() => { clearInterval(interval); if (overlay.exists()) overlay.destroy() }, duration * 1000) + trackedTimeout(() => { clearTracked(interval); if (overlay.exists()) overlay.destroy() }, duration * 1000) } // Schizo cut — rapid zoom/position jitter simulating jump cuts @@ -2549,8 +2566,8 @@ export async function createFightScene(config: FightSceneConfig) { spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ff6600') if (isCritical) { screenFlash('#ff6600'); sfxExplosion() } // Defender on fire briefly - const fireTimer = setInterval(() => { - if (!def.exists()) { clearInterval(fireTimer); return } + const fireTimer = trackedInterval(() => { + if (!def.exists()) { clearTracked(fireTimer); return } k.add([ k.circle(3), k.pos(def.pos.x + (Math.random() - 0.5) * 25, def.pos.y - Math.random() * 60), @@ -2562,7 +2579,7 @@ export async function createFightScene(config: FightSceneConfig) { const push = dir * (isCritical ? 110 : 50) k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad) await k.wait(0.6) - clearInterval(fireTimer) + clearTracked(fireTimer) await Promise.all([ k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad), k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad), @@ -3322,14 +3339,14 @@ export async function createFightScene(config: FightSceneConfig) { const nose = k.add([k.rect(6, 6), k.pos(rocketX + dir * 12, rocketY + 1), k.color(safeColor(k,'#ff2d2d')), k.z(17)]) sfxJetpack() // Trail exhaust as rocket flies - const exhaustInt = setInterval(() => { + const exhaustInt = trackedInterval(() => { const p = k.add([k.circle(3 + Math.random() * 4), k.pos(rocket.pos.x - dir * 12, rocket.pos.y + (Math.random() - 0.5) * 8), k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(16)]) p.onUpdate(() => { p.pos.x -= dir * 100 * k.dt(); p.opacity -= 3 * k.dt(); if (p.opacity <= 0) p.destroy() }) }, 30) // Fly rocket to target const dur = 0.3 await k.tween(0, 1, dur, (t) => { rocket.pos.x = rocketX + (def.pos.x - rocketX) * t; nose.pos.x = rocket.pos.x + dir * 12; rocket.pos.y = rocketY + Math.sin(t * Math.PI * 3) * 8; nose.pos.y = rocket.pos.y + 1 }, k.easings.linear) - clearInterval(exhaustInt) + clearTracked(exhaustInt) rocket.destroy(); nose.destroy() // EXPLOSION! sfxExplosion() @@ -3610,7 +3627,7 @@ export async function createFightScene(config: FightSceneConfig) { const wheelR = k.add([k.circle(8), k.pos(startX - dir * 16, bikeY + 12), k.color(safeColor(k,'#333333')), k.z(16)]) sfxJetpack() // Tire marks + exhaust as it zooms across - const exh = setInterval(() => { + const exh = trackedInterval(() => { const p = k.add([k.circle(3), k.pos(bike.pos.x - dir * 25, bikeY + 5), k.color(safeColor(k,'#888888')), k.opacity(0.5), k.z(4)]) p.onUpdate(() => { p.opacity -= 2 * k.dt(); if (p.opacity <= 0) p.destroy() }) }, 30) @@ -3619,7 +3636,7 @@ export async function createFightScene(config: FightSceneConfig) { await k.tween(startX, targetX, 0.3, (v) => { bike.pos.x = v; wheelF.pos.x = v + dir * 18; wheelR.pos.x = v - dir * 16 }, k.easings.easeInQuad) - clearInterval(exh) + clearTracked(exh) // IMPACT sfxExplosion() sfxCritical() @@ -4574,7 +4591,7 @@ export async function createFightScene(config: FightSceneConfig) { extras.push(k.add([k.rect(p.w, p.h), k.pos(startX + p.ox * dir, vY + p.oy), k.color(safeColor(k,p.color)), k.z(17)])) } sfxJetpack() - const exh = setInterval(() => { + const exh = trackedInterval(() => { const ep = k.add([k.circle(3), k.pos(body.pos.x - dir * bodyW / 2, vY + 5), k.color(safeColor(k,'#888888')), k.opacity(0.5), k.z(4)]) ep.onUpdate(() => { ep.opacity -= 2 * k.dt(); if (ep.opacity <= 0) ep.destroy() }) }, 30) @@ -4583,7 +4600,7 @@ export async function createFightScene(config: FightSceneConfig) { body.pos.x = v; w1.pos.x = v + dir * (bodyW / 3); w2.pos.x = v - dir * (bodyW / 3) extras.forEach((e, i) => { if (parts) e.pos.x = v + parts[i].ox * dir }) }, k.easings.easeInQuad) - clearInterval(exh) + clearTracked(exh) sfxExplosion(); sfxCritical() body.destroy(); w1.destroy(); w2.destroy(); extras.forEach(e => e.destroy()) k.shake(isCritical ? 25 : 14); screenFlash(isCritical ? '#ff2d2d' : bodyColor, 0.18) @@ -7354,7 +7371,7 @@ export async function createFightScene(config: FightSceneConfig) { ]) // Toggle open/close every 120ms for a fast chatter effect - const timer = setInterval(() => { + const timer = trackedInterval(() => { if (!mouth.exists() || !fighter.exists()) { stopTalking(side); return } open = !open mouth.pos.x = fighter.pos.x @@ -7368,7 +7385,7 @@ export async function createFightScene(config: FightSceneConfig) { function stopTalking(side: 'a' | 'b') { const anim = talkingAnims[side] if (!anim) return - clearInterval(anim.timer) + clearTracked(anim.timer) anim.objs.forEach(o => { if (o.exists()) o.destroy() }) delete talkingAnims[side] } @@ -8803,13 +8820,13 @@ export async function createFightScene(config: FightSceneConfig) { sfxJetpack() // Fly in with flame trail const flames: any[] = [] - const interval = setInterval(() => { + const interval = trackedInterval(() => { const f = k.add([k.circle(5 + Math.random() * 5), k.pos(fighter.pos.x, fighter.pos.y + 25), k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.8), k.z(9)]) flames.push(f) k.tween(f.opacity, 0, 0.3, (v) => { f.opacity = v }).then(() => { if (f.exists()) f.destroy() }) }, 30) await k.tween(startX, homeX, 0.4, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad) - clearInterval(interval) + clearTracked(interval) // Land sfxExplosion() await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) @@ -11308,6 +11325,13 @@ export async function createFightScene(config: FightSceneConfig) { destroy() { stopMusic() + stopTalking('a') + stopTalking('b') + for (const id of cleanupTimers) { + clearInterval(id) + clearTimeout(id) + } + cleanupTimers.clear() k.quit() }, } diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index 998adf2..2262b95 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -389,17 +389,24 @@ async function initLiveScene() { } // --- SSE connection --- +const sseListeners: { event: string; handler: EventListener }[] = [] + +function addSSEListener(es: EventSource, event: string, handler: (e: MessageEvent) => void) { + es.addEventListener(event, handler as EventListener) + sseListeners.push({ event, handler: handler as EventListener }) +} + function connectSSE() { eventSource = new EventSource(`/api/fights/${fightId.value}/stream`) - eventSource.addEventListener('spectator_count', (e) => { + addSSEListener(eventSource, 'spectator_count', (e) => { try { const data = JSON.parse(e.data) spectatorCount.value = data.count || 0 } catch { /* ignore */ } }) - eventSource.addEventListener('ping', (e) => { + addSSEListener(eventSource, 'ping', (e) => { try { if (e.data) { const data = JSON.parse(e.data) @@ -408,21 +415,19 @@ function connectSSE() { } catch { /* ignore */ } }) - eventSource.addEventListener('reaction', (e) => { + addSSEListener(eventSource, 'reaction', (e) => { try { const data = JSON.parse(e.data) if (data.emoji && data.counts) { - // Spawn floating emoji in the live view spawnLiveReactionEmoji(data.emoji) } } catch { /* ignore */ } }) - eventSource.addEventListener('round_start', (e) => { + addSSEListener(eventSource, 'round_start', (e) => { try { const data = JSON.parse(e.data) currentChallengeInfo.value = { type: data.challenge.type, label: data.challenge.label } - // Show challenge question in battle log liveLogItems.value.push({ type: 'challenge', round: data.round, @@ -435,10 +440,9 @@ function connectSSE() { } }) - eventSource.addEventListener('human_challenge', async (e) => { + addSSEListener(eventSource, 'human_challenge', async (e) => { try { const sseData = JSON.parse(e.data) - // Fetch from API to get choices (SSE event doesn't include them) if (myBot.value) { const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`) if (res.ok) { @@ -453,7 +457,6 @@ function connectSSE() { } } } - // Fallback: use SSE data without choices if (roundCooldown.value > 0) { pendingChallengeData.value = { data: sseData, receivedAt: Date.now() } } else { @@ -464,7 +467,7 @@ function connectSSE() { } }) - eventSource.addEventListener('round_end', (e) => { + addSSEListener(eventSource, 'round_end', (e) => { try { const data = JSON.parse(e.data) if (data.spectators !== undefined) spectatorCount.value = data.spectators @@ -474,7 +477,7 @@ function connectSSE() { } }) - eventSource.addEventListener('fight_end', (e) => { + addSSEListener(eventSource, 'fight_end', (e) => { try { const data = JSON.parse(e.data) if (data.spectators !== undefined) spectatorCount.value = data.spectators @@ -486,7 +489,6 @@ function connectSSE() { let sseRetries = 0 eventSource.onerror = () => { - // EventSource auto-reconnects, but if it keeps failing, reconnect manually with backoff sseRetries++ if (sseRetries > 5 && eventSource) { eventSource.close() @@ -501,7 +503,14 @@ function connectSSE() { } function disconnectSSE() { - if (eventSource) { eventSource.close(); eventSource = null } + if (eventSource) { + for (const { event, handler } of sseListeners) { + eventSource.removeEventListener(event, handler) + } + sseListeners.length = 0 + eventSource.close() + eventSource = null + } spectatorCount.value = 0 } diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts index d897e2d..9754305 100644 --- a/server/src/middleware/rate-limit.ts +++ b/server/src/middleware/rate-limit.ts @@ -5,7 +5,7 @@ const isDev = process.env.NODE_ENV !== 'production' const hitCounts = new Map() // Cleanup stale entries every 5 minutes -setInterval(() => { +export const cleanupInterval = setInterval(() => { const now = Date.now() for (const [key, entry] of hitCounts) { if (now > entry.resetAt) hitCounts.delete(key)