talk2me/static/js/src/memoryManager.ts
Adolfo Delorenzo 1b9ad03400 Fix potential memory leaks in audio handling - Can crash server after extended use
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>
2025-06-03 08:37:13 -06:00

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';
}
}