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. 60
      src/hooks/useAudioMixing.ts
  3. 15
      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);
audioRef.current = audio;
audio.addEventListener("ended", () => {
const handleEnded = () => {
setIsPlaying(false);
setCurrentTime(0);
});
audio.addEventListener("timeupdate", () => {
};
const handleTimeUpdate = () => {
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 () => {

60
src/hooks/useAudioMixing.ts

@ -45,35 +45,51 @@ async function decodeWithMediaBunny(
): Promise<AudioBuffer[]> {
onProgress?.({ message: 'Reading audio tracks...', progress: 20 });
const blobSource = new BlobSource(audioBlob);
const input = new Input({ source: blobSource, formats: ALL_FORMATS });
let blobSource: BlobSource | undefined;
let input: Input | undefined;
let sink: AudioBufferSink | undefined;
const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) {
throw new Error('No audio tracks found in file');
}
try {
blobSource = new BlobSource(audioBlob);
input = new Input({ source: blobSource, formats: ALL_FORMATS });
const decodable = await audioTrack.canDecode();
if (!decodable) {
throw new Error('Audio codec not supported by this browser');
}
const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) {
throw new Error('No audio tracks found in file');
}
const sink = new AudioBufferSink(audioTrack);
const audioDuration = await input.computeDuration();
const decodable = await audioTrack.canDecode();
if (!decodable) {
throw new Error('Audio codec not supported by this browser');
}
sink = new AudioBufferSink(audioTrack);
const audioDuration = await input.computeDuration();
onProgress?.({ message: 'Decoding audio...', progress: 30 });
const decodedBuffers: AudioBuffer[] = [];
for await (const wrappedBuffer of sink.buffers(0, audioDuration)) {
if (wrappedBuffer?.buffer) {
decodedBuffers.push(wrappedBuffer.buffer);
onProgress?.({ message: 'Decoding audio...', progress: 30 });
const decodedBuffers: AudioBuffer[] = [];
for await (const wrappedBuffer of sink.buffers(0, audioDuration)) {
if (wrappedBuffer?.buffer) {
decodedBuffers.push(wrappedBuffer.buffer);
}
}
}
if (decodedBuffers.length === 0) {
throw new Error('Failed to decode audio');
}
if (decodedBuffers.length === 0) {
throw new Error('Failed to decode audio');
}
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); }
}
}
}
/**

15
src/store/workflowStore.ts

@ -304,17 +304,22 @@ function trackSaveGeneration(
async function waitForPendingImageSyncs(timeout: number = 60000): Promise<void> {
if (pendingImageSyncs.size === 0) return;
let timeoutId: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
timeoutId = setTimeout(() => {
console.warn(`Pending image syncs timed out after ${timeout}ms, continuing with save`);
resolve();
}, timeout);
});
await Promise.race([
Promise.all(pendingImageSyncs.values()),
timeoutPromise,
]);
try {
await Promise.race([
Promise.all(pendingImageSyncs.values()),
timeoutPromise,
]);
} finally {
clearTimeout(timeoutId!);
}
}
// 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)
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
*/
@ -105,9 +108,11 @@ export async function deduplicatedFetch(
.finally(() => {
// Clean up in-flight request after a short delay
// (allows concurrent calls that started just after to still benefit)
setTimeout(() => {
const timeoutId = setTimeout(() => {
inFlightRequests.delete(cacheKey);
pendingTimeouts.delete(cacheKey);
}, 50);
pendingTimeouts.set(cacheKey, timeoutId);
});
// Store the in-flight request
@ -123,4 +128,8 @@ export async function deduplicatedFetch(
export function clearFetchCache(): void {
responseCache.clear();
inFlightRequests.clear();
for (const timeoutId of pendingTimeouts.values()) {
clearTimeout(timeoutId);
}
pendingTimeouts.clear();
}

Loading…
Cancel
Save