Browse Source

fix: plug memory leaks in audio listeners, timeouts, and MediaBunny resources

- AudioInputNode: use named event handlers with removeEventListener in cleanup
- deduplicatedFetch: track setTimeout IDs and clear them in clearFetchCache()
- waitForPendingImageSyncs: clear timeout in finally block after Promise.race
- decodeWithMediaBunny: wrap in try/finally to close sink, input, blobSource

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
e88f108ad1
  1. 19
      src/components/nodes/AudioInputNode.tsx
  2. 22
      src/hooks/useAudioMixing.ts
  3. 7
      src/store/workflowStore.ts
  4. 11
      src/utils/deduplicatedFetch.ts

19
src/components/nodes/AudioInputNode.tsx

@ -45,14 +45,23 @@ export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeT
const audio = new Audio(nodeData.audioFile); const audio = new Audio(nodeData.audioFile);
audioRef.current = audio; audioRef.current = audio;
audio.addEventListener("ended", () => { const handleEnded = () => {
setIsPlaying(false); setIsPlaying(false);
setCurrentTime(0); setCurrentTime(0);
}); };
const handleTimeUpdate = () => {
audio.addEventListener("timeupdate", () => {
setCurrentTime(audio.currentTime); setCurrentTime(audio.currentTime);
}); };
audio.addEventListener("ended", handleEnded);
audio.addEventListener("timeupdate", handleTimeUpdate);
return () => {
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("timeupdate", handleTimeUpdate);
audio.pause();
audioRef.current = null;
};
} }
return () => { return () => {

22
src/hooks/useAudioMixing.ts

@ -45,8 +45,13 @@ async function decodeWithMediaBunny(
): Promise<AudioBuffer[]> { ): Promise<AudioBuffer[]> {
onProgress?.({ message: 'Reading audio tracks...', progress: 20 }); onProgress?.({ message: 'Reading audio tracks...', progress: 20 });
const blobSource = new BlobSource(audioBlob); let blobSource: BlobSource | undefined;
const input = new Input({ source: blobSource, formats: ALL_FORMATS }); let input: Input | undefined;
let sink: AudioBufferSink | undefined;
try {
blobSource = new BlobSource(audioBlob);
input = new Input({ source: blobSource, formats: ALL_FORMATS });
const audioTrack = await input.getPrimaryAudioTrack(); const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) { if (!audioTrack) {
@ -58,7 +63,7 @@ async function decodeWithMediaBunny(
throw new Error('Audio codec not supported by this browser'); throw new Error('Audio codec not supported by this browser');
} }
const sink = new AudioBufferSink(audioTrack); sink = new AudioBufferSink(audioTrack);
const audioDuration = await input.computeDuration(); const audioDuration = await input.computeDuration();
onProgress?.({ message: 'Decoding audio...', progress: 30 }); onProgress?.({ message: 'Decoding audio...', progress: 30 });
@ -74,6 +79,17 @@ async function decodeWithMediaBunny(
} }
return decodedBuffers; return decodedBuffers;
} finally {
if (sink && typeof sink.close === 'function') {
try { await sink.close(); } catch (e) { console.warn('Failed to close sink:', e); }
}
if (input && typeof input.close === 'function') {
try { await input.close(); } catch (e) { console.warn('Failed to close input:', e); }
}
if (blobSource && typeof blobSource.close === 'function') {
try { await blobSource.close(); } catch (e) { console.warn('Failed to close blobSource:', e); }
}
}
} }
/** /**

7
src/store/workflowStore.ts

@ -304,17 +304,22 @@ function trackSaveGeneration(
async function waitForPendingImageSyncs(timeout: number = 60000): Promise<void> { async function waitForPendingImageSyncs(timeout: number = 60000): Promise<void> {
if (pendingImageSyncs.size === 0) return; if (pendingImageSyncs.size === 0) return;
let timeoutId: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<void>((resolve) => { const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => { timeoutId = setTimeout(() => {
console.warn(`Pending image syncs timed out after ${timeout}ms, continuing with save`); console.warn(`Pending image syncs timed out after ${timeout}ms, continuing with save`);
resolve(); resolve();
}, timeout); }, timeout);
}); });
try {
await Promise.race([ await Promise.race([
Promise.all(pendingImageSyncs.values()), Promise.all(pendingImageSyncs.values()),
timeoutPromise, timeoutPromise,
]); ]);
} finally {
clearTimeout(timeoutId!);
}
} }
// Concurrency settings // Concurrency settings

11
src/utils/deduplicatedFetch.ts

@ -19,6 +19,9 @@ const responseCache = new Map<string, { status: number; headers: Record<string,
// Cache TTL in milliseconds (5 seconds - short enough to get fresh data, long enough to dedupe) // Cache TTL in milliseconds (5 seconds - short enough to get fresh data, long enough to dedupe)
const CACHE_TTL = 5000; const CACHE_TTL = 5000;
// Map of cacheKey -> pending cleanup timeout IDs (for clearFetchCache cleanup)
const pendingTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
/** /**
* Generate a cache key from URL and headers * Generate a cache key from URL and headers
*/ */
@ -105,9 +108,11 @@ export async function deduplicatedFetch(
.finally(() => { .finally(() => {
// Clean up in-flight request after a short delay // Clean up in-flight request after a short delay
// (allows concurrent calls that started just after to still benefit) // (allows concurrent calls that started just after to still benefit)
setTimeout(() => { const timeoutId = setTimeout(() => {
inFlightRequests.delete(cacheKey); inFlightRequests.delete(cacheKey);
pendingTimeouts.delete(cacheKey);
}, 50); }, 50);
pendingTimeouts.set(cacheKey, timeoutId);
}); });
// Store the in-flight request // Store the in-flight request
@ -123,4 +128,8 @@ export async function deduplicatedFetch(
export function clearFetchCache(): void { export function clearFetchCache(): void {
responseCache.clear(); responseCache.clear();
inFlightRequests.clear(); inFlightRequests.clear();
for (const timeoutId of pendingTimeouts.values()) {
clearTimeout(timeoutId);
}
pendingTimeouts.clear();
} }

Loading…
Cancel
Save