feat(audio): add goroutine cache cleanup and process reuse

Implement periodic cleanup of stale goroutine buffer caches to prevent memory leaks
Add ability to detect and reuse existing audio input server processes
This commit is contained in:
Alex P 2025-08-29 17:05:37 +00:00
parent 858859e317
commit 6adcc26ff2
2 changed files with 111 additions and 7 deletions

View File

@ -6,8 +6,6 @@ import (
"sync/atomic"
"time"
"unsafe"
"github.com/jetkvm/kvm/internal/logging"
)
// Lock-free buffer cache for per-goroutine optimization
@ -18,6 +16,9 @@ type lockFreeBufferCache struct {
// Per-goroutine buffer cache using goroutine-local storage
var goroutineBufferCache = make(map[int64]*lockFreeBufferCache)
var goroutineCacheMutex sync.RWMutex
var lastCleanupTime int64 // Unix timestamp of last cleanup
const maxCacheSize = 1000 // Maximum number of goroutine caches
const cleanupInterval = 300 // Cleanup interval in seconds (5 minutes)
// getGoroutineID extracts goroutine ID from runtime stack for cache key
func getGoroutineID() int64 {
@ -38,6 +39,41 @@ func getGoroutineID() int64 {
return 0
}
// cleanupGoroutineCache removes stale entries from the goroutine cache
func cleanupGoroutineCache() {
now := time.Now().Unix()
lastCleanup := atomic.LoadInt64(&lastCleanupTime)
// Only cleanup if enough time has passed
if now-lastCleanup < cleanupInterval {
return
}
// Try to acquire cleanup lock atomically
if !atomic.CompareAndSwapInt64(&lastCleanupTime, lastCleanup, now) {
return // Another goroutine is already cleaning up
}
goroutineCacheMutex.Lock()
defer goroutineCacheMutex.Unlock()
// If cache is too large, remove oldest entries (simple FIFO)
if len(goroutineBufferCache) > maxCacheSize {
// Remove half of the entries to avoid frequent cleanups
toRemove := len(goroutineBufferCache) - maxCacheSize/2
count := 0
for gid := range goroutineBufferCache {
delete(goroutineBufferCache, gid)
count++
if count >= toRemove {
break
}
}
// Log cleanup for debugging (removed logging dependency)
_ = count // Avoid unused variable warning
}
}
type AudioBufferPool struct {
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
currentSize int64 // Current pool size (atomic)
@ -57,9 +93,7 @@ type AudioBufferPool struct {
func NewAudioBufferPool(bufferSize int) *AudioBufferPool {
// Validate buffer size parameter
if err := ValidateBufferSize(bufferSize); err != nil {
// Log validation error and use default value
logger := logging.GetDefaultLogger().With().Str("component", "AudioBufferPool").Logger()
logger.Warn().Err(err).Int("bufferSize", bufferSize).Msg("invalid buffer size, using default")
// Use default value on validation error
bufferSize = GetConfig().AudioFramePoolSize
}
@ -99,6 +133,9 @@ func NewAudioBufferPool(bufferSize int) *AudioBufferPool {
}
func (p *AudioBufferPool) Get() []byte {
// Trigger periodic cleanup of goroutine cache
cleanupGoroutineCache()
start := time.Now()
wasHit := false
defer func() {

View File

@ -1,10 +1,15 @@
//go:build cgo
// +build cgo
package audio
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
@ -49,7 +54,20 @@ func (ais *AudioInputSupervisor) Start() error {
defer ais.mutex.Unlock()
if ais.IsRunning() {
return fmt.Errorf("audio input supervisor already running with PID %d", ais.cmd.Process.Pid)
if ais.cmd != nil && ais.cmd.Process != nil {
return fmt.Errorf("audio input supervisor already running with PID %d", ais.cmd.Process.Pid)
}
return fmt.Errorf("audio input supervisor already running")
}
// Check for existing audio input server process
if existingPID, err := ais.findExistingAudioInputProcess(); err == nil {
ais.logger.Info().Int("existing_pid", existingPID).Msg("Found existing audio input server process, connecting to it")
// Try to connect to the existing process
ais.setRunning(true)
go ais.connectClient()
return nil
}
// Create context for subprocess management
@ -204,7 +222,7 @@ func (ais *AudioInputSupervisor) monitorSubprocess() {
ais.client.Disconnect()
}
// Mark as not running
// Mark as not running first to prevent race conditions
ais.setRunning(false)
ais.cmd = nil
@ -264,3 +282,52 @@ func (ais *AudioInputSupervisor) SendConfig(config InputIPCConfig) error {
return ais.client.SendConfig(config)
}
// findExistingAudioInputProcess checks if there's already an audio input server process running
func (ais *AudioInputSupervisor) findExistingAudioInputProcess() (int, error) {
// Get current executable path
execPath, err := os.Executable()
if err != nil {
return 0, fmt.Errorf("failed to get executable path: %w", err)
}
execName := filepath.Base(execPath)
// Use ps to find processes with our executable name and audio-input-server argument
cmd := exec.Command("ps", "aux")
output, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("failed to run ps command: %w", err)
}
// Parse ps output to find audio input server processes
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, execName) && strings.Contains(line, "--audio-input-server") {
// Extract PID from ps output (second column)
fields := strings.Fields(line)
if len(fields) >= 2 {
if pid, err := strconv.Atoi(fields[1]); err == nil {
// Verify the process is still running and accessible
if ais.isProcessRunning(pid) {
return pid, nil
}
}
}
}
}
return 0, fmt.Errorf("no existing audio input server process found")
}
// isProcessRunning checks if a process with the given PID is still running
func (ais *AudioInputSupervisor) isProcessRunning(pid int) bool {
// Try to send signal 0 to check if process exists
process, err := os.FindProcess(pid)
if err != nil {
return false
}
err = process.Signal(syscall.Signal(0))
return err == nil
}