This comprehensive fix addresses memory leaks in both backend and frontend that could cause server crashes after extended use. Backend fixes: - MemoryManager class monitors process and GPU memory usage - Automatic cleanup when thresholds exceeded (4GB process, 2GB GPU) - Whisper model reloading to clear GPU memory fragmentation - Aggressive temporary file cleanup based on age - Context manager for audio processing with guaranteed cleanup - Integration with session manager for resource tracking - Background monitoring thread runs every 30 seconds Frontend fixes: - MemoryManager singleton tracks all browser resources - SafeMediaRecorder wrapper ensures stream cleanup - AudioBlobHandler manages blob lifecycle and object URLs - Automatic cleanup of closed AudioContexts - Proper MediaStream track stopping - Periodic cleanup of orphaned resources - Cleanup on page unload Admin features: - GET /admin/memory - View memory statistics - POST /admin/memory/cleanup - Trigger manual cleanup - Real-time metrics including GPU usage and temp files - Model reload tracking Key improvements: - AudioContext properly closed after use - Object URLs revoked after use - MediaRecorder streams properly stopped - Audio chunks cleared after processing - GPU cache cleared after each transcription - Temp files tracked and cleaned aggressively This prevents the gradual memory increase that could lead to out-of-memory errors or performance degradation after hours of use. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
309 lines
8.0 KiB
TypeScript
309 lines
8.0 KiB
TypeScript
/**
|
|
* Memory management utilities for preventing leaks in audio handling
|
|
*/
|
|
|
|
export class MemoryManager {
|
|
private static instance: MemoryManager;
|
|
private audioContexts: Set<AudioContext> = new Set();
|
|
private objectURLs: Set<string> = new Set();
|
|
private mediaStreams: Set<MediaStream> = new Set();
|
|
private intervals: Set<number> = new Set();
|
|
private timeouts: Set<number> = new Set();
|
|
|
|
private constructor() {
|
|
// Set up periodic cleanup
|
|
this.startPeriodicCleanup();
|
|
|
|
// Clean up on page unload
|
|
window.addEventListener('beforeunload', () => this.cleanup());
|
|
}
|
|
|
|
static getInstance(): MemoryManager {
|
|
if (!MemoryManager.instance) {
|
|
MemoryManager.instance = new MemoryManager();
|
|
}
|
|
return MemoryManager.instance;
|
|
}
|
|
|
|
/**
|
|
* Register an AudioContext for cleanup
|
|
*/
|
|
registerAudioContext(context: AudioContext): void {
|
|
this.audioContexts.add(context);
|
|
}
|
|
|
|
/**
|
|
* Register an object URL for cleanup
|
|
*/
|
|
registerObjectURL(url: string): void {
|
|
this.objectURLs.add(url);
|
|
}
|
|
|
|
/**
|
|
* Register a MediaStream for cleanup
|
|
*/
|
|
registerMediaStream(stream: MediaStream): void {
|
|
this.mediaStreams.add(stream);
|
|
}
|
|
|
|
/**
|
|
* Register an interval for cleanup
|
|
*/
|
|
registerInterval(id: number): void {
|
|
this.intervals.add(id);
|
|
}
|
|
|
|
/**
|
|
* Register a timeout for cleanup
|
|
*/
|
|
registerTimeout(id: number): void {
|
|
this.timeouts.add(id);
|
|
}
|
|
|
|
/**
|
|
* Clean up a specific AudioContext
|
|
*/
|
|
cleanupAudioContext(context: AudioContext): void {
|
|
if (context.state !== 'closed') {
|
|
context.close().catch(console.error);
|
|
}
|
|
this.audioContexts.delete(context);
|
|
}
|
|
|
|
/**
|
|
* Clean up a specific object URL
|
|
*/
|
|
cleanupObjectURL(url: string): void {
|
|
URL.revokeObjectURL(url);
|
|
this.objectURLs.delete(url);
|
|
}
|
|
|
|
/**
|
|
* Clean up a specific MediaStream
|
|
*/
|
|
cleanupMediaStream(stream: MediaStream): void {
|
|
stream.getTracks().forEach(track => {
|
|
track.stop();
|
|
});
|
|
this.mediaStreams.delete(stream);
|
|
}
|
|
|
|
/**
|
|
* Clean up all resources
|
|
*/
|
|
cleanup(): void {
|
|
// Clean up audio contexts
|
|
this.audioContexts.forEach(context => {
|
|
if (context.state !== 'closed') {
|
|
context.close().catch(console.error);
|
|
}
|
|
});
|
|
this.audioContexts.clear();
|
|
|
|
// Clean up object URLs
|
|
this.objectURLs.forEach(url => {
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
this.objectURLs.clear();
|
|
|
|
// Clean up media streams
|
|
this.mediaStreams.forEach(stream => {
|
|
stream.getTracks().forEach(track => {
|
|
track.stop();
|
|
});
|
|
});
|
|
this.mediaStreams.clear();
|
|
|
|
// Clear intervals and timeouts
|
|
this.intervals.forEach(id => clearInterval(id));
|
|
this.intervals.clear();
|
|
|
|
this.timeouts.forEach(id => clearTimeout(id));
|
|
this.timeouts.clear();
|
|
|
|
console.log('Memory cleanup completed');
|
|
}
|
|
|
|
/**
|
|
* Get memory usage statistics
|
|
*/
|
|
getStats(): MemoryStats {
|
|
return {
|
|
audioContexts: this.audioContexts.size,
|
|
objectURLs: this.objectURLs.size,
|
|
mediaStreams: this.mediaStreams.size,
|
|
intervals: this.intervals.size,
|
|
timeouts: this.timeouts.size
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start periodic cleanup of orphaned resources
|
|
*/
|
|
private startPeriodicCleanup(): void {
|
|
setInterval(() => {
|
|
// Clean up closed audio contexts
|
|
this.audioContexts.forEach(context => {
|
|
if (context.state === 'closed') {
|
|
this.audioContexts.delete(context);
|
|
}
|
|
});
|
|
|
|
// Clean up stopped media streams
|
|
this.mediaStreams.forEach(stream => {
|
|
const activeTracks = stream.getTracks().filter(track => track.readyState === 'live');
|
|
if (activeTracks.length === 0) {
|
|
this.mediaStreams.delete(stream);
|
|
}
|
|
});
|
|
|
|
// Log stats in development
|
|
if (process.env.NODE_ENV === 'development') {
|
|
const stats = this.getStats();
|
|
if (Object.values(stats).some(v => v > 0)) {
|
|
console.log('Memory manager stats:', stats);
|
|
}
|
|
}
|
|
}, 30000); // Every 30 seconds
|
|
|
|
// Don't track this interval to avoid self-reference
|
|
// It will be cleared on page unload
|
|
}
|
|
}
|
|
|
|
interface MemoryStats {
|
|
audioContexts: number;
|
|
objectURLs: number;
|
|
mediaStreams: number;
|
|
intervals: number;
|
|
timeouts: number;
|
|
}
|
|
|
|
/**
|
|
* Wrapper for safe audio blob handling
|
|
*/
|
|
export class AudioBlobHandler {
|
|
private blob: Blob;
|
|
private objectURL?: string;
|
|
private memoryManager: MemoryManager;
|
|
|
|
constructor(blob: Blob) {
|
|
this.blob = blob;
|
|
this.memoryManager = MemoryManager.getInstance();
|
|
}
|
|
|
|
/**
|
|
* Get object URL (creates one if needed)
|
|
*/
|
|
getObjectURL(): string {
|
|
if (!this.objectURL) {
|
|
this.objectURL = URL.createObjectURL(this.blob);
|
|
this.memoryManager.registerObjectURL(this.objectURL);
|
|
}
|
|
return this.objectURL;
|
|
}
|
|
|
|
/**
|
|
* Get the blob
|
|
*/
|
|
getBlob(): Blob {
|
|
return this.blob;
|
|
}
|
|
|
|
/**
|
|
* Clean up resources
|
|
*/
|
|
cleanup(): void {
|
|
if (this.objectURL) {
|
|
this.memoryManager.cleanupObjectURL(this.objectURL);
|
|
this.objectURL = undefined;
|
|
}
|
|
// Help garbage collection
|
|
(this.blob as any) = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Safe MediaRecorder wrapper
|
|
*/
|
|
export class SafeMediaRecorder {
|
|
private mediaRecorder?: MediaRecorder;
|
|
private stream?: MediaStream;
|
|
private chunks: Blob[] = [];
|
|
private memoryManager: MemoryManager;
|
|
|
|
constructor() {
|
|
this.memoryManager = MemoryManager.getInstance();
|
|
}
|
|
|
|
async start(constraints: MediaStreamConstraints = { audio: true }): Promise<void> {
|
|
// Clean up any existing recorder
|
|
this.cleanup();
|
|
|
|
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
this.memoryManager.registerMediaStream(this.stream);
|
|
|
|
const options = {
|
|
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
? 'audio/webm;codecs=opus'
|
|
: 'audio/webm'
|
|
};
|
|
|
|
this.mediaRecorder = new MediaRecorder(this.stream, options);
|
|
this.chunks = [];
|
|
|
|
this.mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
this.chunks.push(event.data);
|
|
}
|
|
};
|
|
|
|
this.mediaRecorder.start();
|
|
}
|
|
|
|
stop(): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.mediaRecorder) {
|
|
reject(new Error('MediaRecorder not initialized'));
|
|
return;
|
|
}
|
|
|
|
this.mediaRecorder.onstop = () => {
|
|
const blob = new Blob(this.chunks, {
|
|
type: this.mediaRecorder?.mimeType || 'audio/webm'
|
|
});
|
|
resolve(blob);
|
|
|
|
// Clean up after delivering the blob
|
|
setTimeout(() => this.cleanup(), 100);
|
|
};
|
|
|
|
this.mediaRecorder.stop();
|
|
});
|
|
}
|
|
|
|
cleanup(): void {
|
|
if (this.stream) {
|
|
this.memoryManager.cleanupMediaStream(this.stream);
|
|
this.stream = undefined;
|
|
}
|
|
|
|
if (this.mediaRecorder) {
|
|
if (this.mediaRecorder.state !== 'inactive') {
|
|
try {
|
|
this.mediaRecorder.stop();
|
|
} catch (e) {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
this.mediaRecorder = undefined;
|
|
}
|
|
|
|
// Clear chunks
|
|
this.chunks = [];
|
|
}
|
|
|
|
isRecording(): boolean {
|
|
return this.mediaRecorder?.state === 'recording';
|
|
}
|
|
} |