Stability: prevent race condition when clicking on Mic Start, Stop buttons in quick succession

This commit is contained in:
Alex P 2025-08-05 09:02:21 +03:00
parent 3c1f96d49c
commit 94ca3fa3f4
4 changed files with 103 additions and 10 deletions

View File

@ -64,8 +64,7 @@ func (aim *AudioInputManager) Stop() {
aim.logger.Info().Msg("Stopping audio input manager")
// Stop the non-blocking audio input stream
// Note: This is handled by the global non-blocking audio manager
// Individual input streams are managed centrally
StopNonBlockingAudioInput()
// Drain the input buffer
go func() {
@ -78,6 +77,8 @@ func (aim *AudioInputManager) Stop() {
}
}
}()
aim.logger.Info().Msg("Audio input manager stopped")
}
// WriteOpusFrame writes an Opus frame to the input buffer

View File

@ -413,6 +413,10 @@ func (nam *NonBlockingAudioManager) StopAudioInput() {
// Stop only the input coordinator
atomic.StoreInt32(&nam.inputRunning, 0)
// Allow coordinator thread to process the stop signal and update state
// This prevents race conditions in state queries immediately after stopping
time.Sleep(50 * time.Millisecond)
nam.logger.Info().Msg("audio input stopped")
}

View File

@ -327,11 +327,18 @@ export function useMicrophone() {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
// If this is a retry, first try to stop the backend microphone to reset state
// If this is a retry, first try to reset the backend microphone state
if (attempt > 1) {
console.log(`Backend start attempt ${attempt}, first trying to reset backend state...`);
try {
await api.POST("/microphone/stop", {});
// Try the new reset endpoint first
const resetResp = await api.POST("/microphone/reset", {});
if (resetResp.ok) {
console.log("Backend reset successful");
} else {
// Fallback to stop
await api.POST("/microphone/stop", {});
}
// Wait a bit for the backend to reset
await new Promise(resolve => setTimeout(resolve, 200));
} catch (resetError) {
@ -358,6 +365,24 @@ export function useMicrophone() {
console.log("Backend response data:", responseData);
if (responseData.status === "already running") {
console.info("Backend microphone was already running");
// If we're on the first attempt and backend says "already running",
// but frontend thinks it's not active, this might be a stuck state
if (attempt === 1 && !isMicrophoneActive) {
console.warn("Backend reports 'already running' but frontend is not active - possible stuck state");
console.log("Attempting to reset backend state and retry...");
try {
const resetResp = await api.POST("/microphone/reset", {});
if (resetResp.ok) {
console.log("Backend reset successful, retrying start...");
await new Promise(resolve => setTimeout(resolve, 200));
continue; // Retry the start
}
} catch (resetError) {
console.warn("Failed to reset stuck backend state:", resetError);
}
}
}
console.log("Backend microphone start successful");
backendSuccess = true;
@ -457,15 +482,47 @@ export function useMicrophone() {
const resetBackendMicrophoneState = useCallback(async (): Promise<boolean> => {
try {
console.log("Resetting backend microphone state...");
await api.POST("/microphone/stop", {});
// Wait for backend to process the stop
await new Promise(resolve => setTimeout(resolve, 300));
return true;
const response = await api.POST("/microphone/reset", {});
if (response.ok) {
const data = await response.json();
console.log("Backend microphone reset successful:", data);
// Update frontend state to match backend
setMicrophoneActive(false);
setMicrophoneMuted(false);
// Clean up any orphaned streams
if (microphoneStreamRef.current) {
console.log("Cleaning up orphaned stream after reset");
await stopMicrophoneStream();
}
// Wait a bit for everything to settle
await new Promise(resolve => setTimeout(resolve, 200));
// Sync state to ensure consistency
await syncMicrophoneState();
return true;
} else {
console.error("Backend microphone reset failed:", response.status);
return false;
}
} catch (error) {
console.warn("Failed to reset backend microphone state:", error);
return false;
// Fallback to old method
try {
console.log("Trying fallback reset method...");
await api.POST("/microphone/stop", {});
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (fallbackError) {
console.error("Fallback reset also failed:", fallbackError);
return false;
}
}
}, []);
}, [setMicrophoneActive, setMicrophoneMuted, stopMicrophoneStream, syncMicrophoneState]);
// Stop microphone
const stopMicrophone = useCallback(async (): Promise<{ success: boolean; error?: MicrophoneError }> => {

31
web.go
View File

@ -398,6 +398,37 @@ func setupRouter() *gin.Engine {
})
})
protected.POST("/microphone/reset", func(c *gin.Context) {
if currentSession == nil {
c.JSON(400, gin.H{"error": "no active session"})
return
}
if currentSession.AudioInputManager == nil {
c.JSON(500, gin.H{"error": "audio input manager not available"})
return
}
logger.Info().Msg("forcing microphone state reset")
// Force stop both the AudioInputManager and NonBlockingAudioManager
currentSession.AudioInputManager.Stop()
audio.StopNonBlockingAudioInput()
// Wait a bit to ensure everything is stopped
time.Sleep(100 * time.Millisecond)
// Broadcast microphone state change via WebSocket
broadcaster := audio.GetAudioEventBroadcaster()
broadcaster.BroadcastMicrophoneStateChanged(false, true)
c.JSON(200, gin.H{
"status": "reset",
"audio_input_running": currentSession.AudioInputManager.IsRunning(),
"nonblocking_input_running": audio.IsNonBlockingAudioInputRunning(),
})
})
// Catch-all route for SPA
r.NoRoute(func(c *gin.Context) {
if c.Request.Method == "GET" && c.NegotiateFormat(gin.MIMEHTML) == gin.MIMEHTML {