Compare commits

..

6 Commits

Author SHA1 Message Date
Alex 2f2f45ddf0
Merge 9e95cc3f8a into 31ea366e51 2025-11-10 16:01:48 +01:00
Alex P 9e95cc3f8a fix: prevent audio disconnect from blocking new WebRTC sessions
When an old connection closed while a new one started, the audio cleanup
would hold audioMutex for up to 37 seconds during CGO disconnect calls,
blocking the new session from initializing.

Use capture-clear-release pattern to minimize mutex hold time:
- Capture references to sources/relays while holding mutex
- Clear globals immediately so new sessions can proceed
- Release mutex before calling blocking Stop/Disconnect operations

This eliminates the 37-second hang during connection transitions.
2025-11-10 16:54:31 +02:00
Marc Brooks 31ea366e51
chore: upgrade UI packages (except vite) (#954) 2025-11-10 14:53:18 +01:00
Alex P b9705f4bac Revert "refactor: use atomic.Pointer for thread-safe inputSource access"
This reverts commit 41345b0527.
2025-11-10 15:42:10 +02:00
Marc Brooks 7f2dcc84b4
fix: don't reload page if we didn't attempt an upgrade. (#955) 2025-11-10 12:58:57 +01:00
Adam Shiervani bc1992ea13 refactor: fix infinite useEffect 2025-11-10 09:43:10 +01:00
9 changed files with 580 additions and 635 deletions

View File

@ -205,12 +205,14 @@ lint-go-fix: build_audio_deps
# Run UI linting locally (mirrors GitHub workflow ui-lint.yml) # Run UI linting locally (mirrors GitHub workflow ui-lint.yml)
lint-ui: lint-ui:
@echo "Running UI lint..." @echo "Running UI lint..."
@cd ui && npm ci && npm run lint @cd ui && npm ci
@cd ui && npm run lint
# Run UI linting with auto-fix # Run UI linting with auto-fix
lint-ui-fix: lint-ui-fix:
@echo "Running UI lint with auto-fix..." @echo "Running UI lint with auto-fix..."
@cd ui && npm ci && npm run lint:fix @cd ui && npm ci
@cd ui && npm run lint:fix
# Legacy alias for UI linting (for backward compatibility) # Legacy alias for UI linting (for backward compatibility)
ui-lint: lint-ui ui-lint: lint-ui

109
audio.go
View File

@ -14,7 +14,7 @@ import (
var ( var (
audioMutex sync.Mutex audioMutex sync.Mutex
outputSource audio.AudioSource outputSource audio.AudioSource
inputSource atomic.Pointer[audio.AudioSource] inputSource audio.AudioSource
outputRelay *audio.OutputRelay outputRelay *audio.OutputRelay
inputRelay *audio.InputRelay inputRelay *audio.InputRelay
audioInitialized bool audioInitialized bool
@ -63,15 +63,13 @@ func startAudio() error {
// Start input audio if not running, USB audio enabled, and input enabled // Start input audio if not running, USB audio enabled, and input enabled
ensureConfigLoaded() ensureConfigLoaded()
if inputSource.Load() == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio { if inputSource == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
alsaPlaybackDevice := "hw:1,0" // USB speakers alsaPlaybackDevice := "hw:1,0" // USB speakers
// Create CGO audio source // Create CGO audio source
newInputSource := audio.NewCgoInputSource(alsaPlaybackDevice) inputSource = audio.NewCgoInputSource(alsaPlaybackDevice)
var audioSrc audio.AudioSource = newInputSource
inputSource.Store(&audioSrc)
inputRelay = audio.NewInputRelay(newInputSource) inputRelay = audio.NewInputRelay(inputSource)
if err := inputRelay.Start(); err != nil { if err := inputRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start input relay") audioLogger.Error().Err(err).Msg("Failed to start input relay")
} }
@ -80,41 +78,31 @@ func startAudio() error {
return nil return nil
} }
// stopOutputLocked stops output audio (assumes mutex is held)
func stopOutputLocked() {
if outputRelay != nil {
outputRelay.Stop()
outputRelay = nil
}
if outputSource != nil {
outputSource.Disconnect()
outputSource = nil
}
}
// stopInputLocked stops input audio (assumes mutex is held)
func stopInputLocked() {
if inputRelay != nil {
inputRelay.Stop()
inputRelay = nil
}
if source := inputSource.Load(); source != nil {
(*source).Disconnect()
inputSource.Store(nil)
}
}
// stopAudioLocked stops all audio (assumes mutex is held)
func stopAudioLocked() {
stopOutputLocked()
stopInputLocked()
}
// stopAudio stops all audio
func stopAudio() { func stopAudio() {
audioMutex.Lock() audioMutex.Lock()
defer audioMutex.Unlock() outRelay := outputRelay
stopAudioLocked() outSource := outputSource
inRelay := inputRelay
inSource := inputSource
outputRelay = nil
outputSource = nil
inputRelay = nil
inputSource = nil
audioMutex.Unlock()
// Disconnect outside mutex to avoid blocking new sessions during CGO calls
if outRelay != nil {
outRelay.Stop()
}
if outSource != nil {
outSource.Disconnect()
}
if inRelay != nil {
inRelay.Stop()
}
if inSource != nil {
inSource.Disconnect()
}
} }
func onWebRTCConnect() { func onWebRTCConnect() {
@ -171,8 +159,18 @@ func SetAudioOutputEnabled(enabled bool) error {
} }
} else { } else {
audioMutex.Lock() audioMutex.Lock()
defer audioMutex.Unlock() outRelay := outputRelay
stopOutputLocked() outSource := outputSource
outputRelay = nil
outputSource = nil
audioMutex.Unlock()
if outRelay != nil {
outRelay.Stop()
}
if outSource != nil {
outSource.Disconnect()
}
} }
return nil return nil
@ -190,8 +188,18 @@ func SetAudioInputEnabled(enabled bool) error {
} }
} else { } else {
audioMutex.Lock() audioMutex.Lock()
defer audioMutex.Unlock() inRelay := inputRelay
stopInputLocked() inSource := inputSource
inputRelay = nil
inputSource = nil
audioMutex.Unlock()
if inRelay != nil {
inRelay.Stop()
}
if inSource != nil {
inSource.Disconnect()
}
} }
return nil return nil
@ -240,22 +248,23 @@ func handleInputTrackForSession(track *webrtc.TrackRemote) {
continue // Drop frame but keep reading continue // Drop frame but keep reading
} }
// Get source atomically (hot path optimization) // Get source in single mutex operation (hot path optimization)
source := inputSource.Load() audioMutex.Lock()
source := inputSource
audioMutex.Unlock()
if source == nil { if source == nil {
continue // No relay, drop frame but keep reading continue // No relay, drop frame but keep reading
} }
if !(*source).IsConnected() { if !source.IsConnected() {
if err := (*source).Connect(); err != nil { if err := source.Connect(); err != nil {
continue continue
} }
} }
if err := (*source).WriteMessage(0, opusData); err != nil { if err := source.WriteMessage(0, opusData); err != nil {
(*source).Disconnect() source.Disconnect()
audioLogger.Warn().Err(err).Str("track_id", myTrackID).Msg("failed to write audio message")
} }
} }
} }

View File

@ -894,11 +894,20 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
func updateUsbRelatedConfig(wasAudioEnabled bool) error { func updateUsbRelatedConfig(wasAudioEnabled bool) error {
ensureConfigLoaded() ensureConfigLoaded()
// Stop input audio before USB reconfiguration (input uses USB)
audioMutex.Lock() audioMutex.Lock()
stopInputLocked() inRelay := inputRelay
inSource := inputSource
inputRelay = nil
inputSource = nil
audioMutex.Unlock() audioMutex.Unlock()
if inRelay != nil {
inRelay.Stop()
}
if inSource != nil {
inSource.Disconnect()
}
if err := gadget.UpdateGadgetConfig(); err != nil { if err := gadget.UpdateGadgetConfig(); err != nil {
return fmt.Errorf("failed to write gadget config: %w", err) return fmt.Errorf("failed to write gadget config: %w", err)
} }

View File

@ -133,7 +133,7 @@ func Main() {
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs <-sigs
logger.Log().Msg("JetKVM Shutting Down") logger.Info().Msg("JetKVM Shutting Down")
stopAudio() stopAudio()

1004
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "kvm-ui", "name": "kvm-ui",
"private": true, "private": true,
"version": "2025.10.24.2140", "version": "2025.11.07.2130",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "^22.20.0" "node": "^22.20.0"
@ -38,7 +38,7 @@
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.4", "cva": "^1.0.0-beta.4",
"dayjs": "^1.11.18", "dayjs": "^1.11.19",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^11.0.4", "focus-trap-react": "^11.0.4",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
@ -47,52 +47,52 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.65.0", "react-hook-form": "^7.66.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router": "^7.9.5", "react-router": "^7.9.5",
"react-simple-keyboard": "^3.8.131", "react-simple-keyboard": "^3.8.132",
"react-use-websocket": "^4.13.0", "react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^3.3.0", "recharts": "^3.3.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"validator": "^13.15.15", "validator": "^13.15.20",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.1", "@eslint/compat": "^1.4.1",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.0", "@eslint/js": "^9.39.1",
"@inlang/cli": "^3.0.12", "@inlang/cli": "^3.0.12",
"@inlang/paraglide-js": "^2.4.0", "@inlang/paraglide-js": "^2.4.0",
"@inlang/plugin-m-function-matcher": "^2.1.0", "@inlang/plugin-m-function-matcher": "^2.1.0",
"@inlang/plugin-message-format": "^4.0.0", "@inlang/plugin-message-format": "^4.0.0",
"@inlang/sdk": "^2.4.9", "@inlang/sdk": "^2.4.9",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.16", "@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.17",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"@types/validator": "^13.15.3", "@types/validator": "^13.15.4",
"@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.2", "@typescript-eslint/parser": "^8.46.3",
"@vitejs/plugin-react-swc": "^4.2.0", "@vitejs/plugin-react-swc": "^4.2.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.38.0", "eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.4.0", "globals": "^16.5.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.12", "vite": "^7.1.12",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"

View File

@ -628,6 +628,9 @@ export interface UpdateState {
updateErrorMessage: string | null; updateErrorMessage: string | null;
setUpdateErrorMessage: (errorMessage: string) => void; setUpdateErrorMessage: (errorMessage: string) => void;
shouldReload: boolean;
setShouldReload: (reloadRequired: boolean) => void;
} }
export const useUpdateStore = create<UpdateState>(set => ({ export const useUpdateStore = create<UpdateState>(set => ({
@ -665,6 +668,9 @@ export const useUpdateStore = create<UpdateState>(set => ({
updateErrorMessage: null, updateErrorMessage: null,
setUpdateErrorMessage: (errorMessage: string) => setUpdateErrorMessage: (errorMessage: string) =>
set({ updateErrorMessage: errorMessage }), set({ updateErrorMessage: errorMessage }),
shouldReload: false,
setShouldReload: (reloadRequired: boolean) => set({ shouldReload: reloadRequired }),
})); }));
export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess"; export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess";

View File

@ -12,19 +12,19 @@ import notifications from "../notifications";
export default function SettingsAudioRoute() { export default function SettingsAudioRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const settings = useSettingsStore(); const { setAudioOutputEnabled, setAudioInputAutoEnable, audioOutputEnabled, audioInputAutoEnable } = useSettingsStore();
useEffect(() => { useEffect(() => {
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => { send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
settings.setAudioOutputEnabled(resp.result as boolean); setAudioOutputEnabled(resp.result as boolean);
}); });
send("getAudioInputAutoEnable", {}, (resp: JsonRpcResponse) => { send("getAudioInputAutoEnable", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
settings.setAudioInputAutoEnable(resp.result as boolean); setAudioInputAutoEnable(resp.result as boolean);
}); });
}, [send, settings]); }, [send, setAudioOutputEnabled, setAudioInputAutoEnable]);
const handleAudioOutputEnabledChange = (enabled: boolean) => { const handleAudioOutputEnabledChange = (enabled: boolean) => {
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => { send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
@ -35,7 +35,7 @@ export default function SettingsAudioRoute() {
notifications.error(errorMsg); notifications.error(errorMsg);
return; return;
} }
settings.setAudioOutputEnabled(enabled); setAudioOutputEnabled(enabled);
const successMsg = enabled ? m.audio_output_enabled() : m.audio_output_disabled(); const successMsg = enabled ? m.audio_output_enabled() : m.audio_output_disabled();
notifications.success(successMsg); notifications.success(successMsg);
}); });
@ -47,7 +47,7 @@ export default function SettingsAudioRoute() {
notifications.error(String(resp.error.data || m.unknown_error())); notifications.error(String(resp.error.data || m.unknown_error()));
return; return;
} }
settings.setAudioInputAutoEnable(enabled); setAudioInputAutoEnable(enabled);
const successMsg = enabled const successMsg = enabled
? m.audio_input_auto_enable_enabled() ? m.audio_input_auto_enable_enabled()
: m.audio_input_auto_enable_disabled(); : m.audio_input_auto_enable_disabled();
@ -67,7 +67,7 @@ export default function SettingsAudioRoute() {
description={m.audio_settings_output_description()} description={m.audio_settings_output_description()}
> >
<Checkbox <Checkbox
checked={settings.audioOutputEnabled || false} checked={audioOutputEnabled || false}
onChange={(e) => handleAudioOutputEnabledChange(e.target.checked)} onChange={(e) => handleAudioOutputEnabledChange(e.target.checked)}
/> />
</SettingsItem> </SettingsItem>
@ -77,7 +77,7 @@ export default function SettingsAudioRoute() {
description={m.audio_settings_auto_enable_microphone_description()} description={m.audio_settings_auto_enable_microphone_description()}
> >
<Checkbox <Checkbox
checked={settings.audioInputAutoEnable || false} checked={audioInputAutoEnable || false}
onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)} onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)}
/> />
</SettingsItem> </SettingsItem>

View File

@ -18,20 +18,24 @@ export default function SettingsGeneralUpdateRoute() {
const location = useLocation(); const location = useLocation();
const { updateSuccess } = location.state || {}; const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore(); const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const onClose = useCallback(async () => { const onClose = useCallback(async () => {
navigate(".."); // back to the devices.$id.settings page navigate(".."); // back to the devices.$id.settings page
// Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
await sleep(1000); if (shouldReload) {
setShouldReload(false);
await sleep(1000); // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded
}, [navigate]); }
}, [navigate, setShouldReload, shouldReload]);
const onConfirmUpdate = useCallback(() => { const onConfirmUpdate = useCallback(() => {
setShouldReload(true);
send("tryUpdate", {}); send("tryUpdate", {});
setModalView("updating"); setModalView("updating");
}, [send, setModalView]); }, [send, setModalView, setShouldReload]);
useEffect(() => { useEffect(() => {
if (otaState.updating) { if (otaState.updating) {
@ -133,6 +137,7 @@ function LoadingState({
const { setModalView } = useUpdateStore(); const { setModalView } = useUpdateStore();
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal; const signal = abortControllerRef.current.signal;