refactor: More robust handling of jsonrpc calls (#915) (#967)

Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
Co-authored-by: Marc Brooks <IDisposable@gmail.com>
This commit is contained in:
Aveline 2025-11-17 09:54:49 +01:00 committed by GitHub
parent 5c74101058
commit 97810a421e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 305 additions and 43 deletions

View File

@ -102,7 +102,7 @@ export interface RTCState {
peerConnection: RTCPeerConnection | null; peerConnection: RTCPeerConnection | null;
setPeerConnection: (pc: RTCState["peerConnection"]) => void; setPeerConnection: (pc: RTCState["peerConnection"]) => void;
setRpcDataChannel: (channel: RTCDataChannel) => void; setRpcDataChannel: (channel: RTCDataChannel | null) => void;
rpcDataChannel: RTCDataChannel | null; rpcDataChannel: RTCDataChannel | null;
hidRpcDisabled: boolean; hidRpcDisabled: boolean;
@ -164,41 +164,42 @@ export const useRTCStore = create<RTCState>(set => ({
setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }), setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }),
rpcDataChannel: null, rpcDataChannel: null,
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
hidRpcDisabled: false, hidRpcDisabled: false,
setHidRpcDisabled: (disabled: boolean) => set({ hidRpcDisabled: disabled }), setHidRpcDisabled: disabled => set({ hidRpcDisabled: disabled }),
rpcHidProtocolVersion: null, rpcHidProtocolVersion: null,
setRpcHidProtocolVersion: (version: number | null) => set({ rpcHidProtocolVersion: version }), setRpcHidProtocolVersion: version => set({ rpcHidProtocolVersion: version }),
rpcHidChannel: null, rpcHidChannel: null,
setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }), setRpcHidChannel: channel => set({ rpcHidChannel: channel }),
rpcHidUnreliableChannel: null, rpcHidUnreliableChannel: null,
setRpcHidUnreliableChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableChannel: channel }), setRpcHidUnreliableChannel: channel => set({ rpcHidUnreliableChannel: channel }),
rpcHidUnreliableNonOrderedChannel: null, rpcHidUnreliableNonOrderedChannel: null,
setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableNonOrderedChannel: channel }), setRpcHidUnreliableNonOrderedChannel: channel =>
set({ rpcHidUnreliableNonOrderedChannel: channel }),
transceiver: null, transceiver: null,
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), setTransceiver: transceiver => set({ transceiver }),
peerConnectionState: null, peerConnectionState: null,
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }), setPeerConnectionState: state => set({ peerConnectionState: state }),
mediaStream: null, mediaStream: null,
setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }), setMediaStream: stream => set({ mediaStream: stream }),
videoStreamStats: null, videoStreamStats: null,
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }), appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(), videoStreamStatsHistory: new Map(),
isTurnServerInUse: false, isTurnServerInUse: false,
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }), setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(), inboundRtpStats: new Map(),
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => { appendInboundRtpStats: stats => {
set(prevState => ({ set(prevState => ({
inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats), inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats),
})); }));
@ -206,7 +207,7 @@ export const useRTCStore = create<RTCState>(set => ({
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
candidatePairStats: new Map(), candidatePairStats: new Map(),
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => { appendCandidatePairStats: stats => {
set(prevState => ({ set(prevState => ({
candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats), candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
})); }));
@ -214,21 +215,21 @@ export const useRTCStore = create<RTCState>(set => ({
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
localCandidateStats: new Map(), localCandidateStats: new Map(),
appendLocalCandidateStats: (stats: RTCIceCandidateStats) => { appendLocalCandidateStats: stats => {
set(prevState => ({ set(prevState => ({
localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats), localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats),
})); }));
}, },
remoteCandidateStats: new Map(), remoteCandidateStats: new Map(),
appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => { appendRemoteCandidateStats: stats => {
set(prevState => ({ set(prevState => ({
remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats), remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats),
})); }));
}, },
diskDataChannelStats: new Map(), diskDataChannelStats: new Map(),
appendDiskDataChannelStats: (stats: RTCDataChannelStats) => { appendDiskDataChannelStats: stats => {
set(prevState => ({ set(prevState => ({
diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats), diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats),
})); }));
@ -236,7 +237,7 @@ export const useRTCStore = create<RTCState>(set => ({
// Add these new properties to the store implementation // Add these new properties to the store implementation
terminalChannel: null, terminalChannel: null,
setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }), setTerminalChannel: channel => set({ terminalChannel: channel }),
})); }));
export interface MouseMove { export interface MouseMove {
@ -255,12 +256,20 @@ export interface MouseState {
export const useMouseStore = create<MouseState>(set => ({ export const useMouseStore = create<MouseState>(set => ({
mouseX: 0, mouseX: 0,
mouseY: 0, mouseY: 0,
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), setMouseMove: move => set({ mouseMove: move }),
setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }), setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
})); }));
export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; export type HdmiStates =
export type HdmiErrorStates = Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range"> | "ready"
| "no_signal"
| "no_lock"
| "out_of_range"
| "connecting";
export type HdmiErrorStates = Extract<
VideoState["hdmiState"],
"no_signal" | "no_lock" | "out_of_range"
>;
export interface HdmiState { export interface HdmiState {
ready: boolean; ready: boolean;
@ -275,10 +284,7 @@ export interface VideoState {
setClientSize: (width: number, height: number) => void; setClientSize: (width: number, height: number) => void;
setSize: (width: number, height: number) => void; setSize: (width: number, height: number) => void;
hdmiState: HdmiStates; hdmiState: HdmiStates;
setHdmiState: (state: { setHdmiState: (state: { ready: boolean; error?: HdmiErrorStates }) => void;
ready: boolean;
error?: HdmiErrorStates;
}) => void;
} }
export const useVideoStore = create<VideoState>(set => ({ export const useVideoStore = create<VideoState>(set => ({
@ -289,7 +295,8 @@ export const useVideoStore = create<VideoState>(set => ({
clientHeight: 0, clientHeight: 0,
// The video element's client size // The video element's client size
setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }), setClientSize: (clientWidth: number, clientHeight: number) =>
set({ clientWidth, clientHeight }),
// Resolution // Resolution
setSize: (width: number, height: number) => set({ width, height }), setSize: (width: number, height: number) => set({ width, height }),
@ -432,13 +439,15 @@ export interface MountMediaState {
export const useMountMediaStore = create<MountMediaState>(set => ({ export const useMountMediaStore = create<MountMediaState>(set => ({
remoteVirtualMediaState: null, remoteVirtualMediaState: null,
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }), setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) =>
set({ remoteVirtualMediaState: state }),
modalView: "mode", modalView: "mode",
setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }), setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }),
isMountMediaDialogOpen: false, isMountMediaDialogOpen: false,
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }), setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) =>
set({ isMountMediaDialogOpen: isOpen }),
uploadedFiles: [], uploadedFiles: [],
addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) =>
@ -455,7 +464,7 @@ export interface KeyboardLedState {
compose: boolean; compose: boolean;
kana: boolean; kana: boolean;
shift: boolean; // Optional, as not all keyboards have a shift LED shift: boolean; // Optional, as not all keyboards have a shift LED
}; }
export const hidKeyBufferSize = 6; export const hidKeyBufferSize = 6;
export const hidErrorRollOver = 0x01; export const hidErrorRollOver = 0x01;
@ -490,14 +499,23 @@ export interface HidState {
} }
export const useHidStore = create<HidState>(set => ({ export const useHidStore = create<HidState>(set => ({
keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false, shift: false } as KeyboardLedState, keyboardLedState: {
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), num_lock: false,
caps_lock: false,
scroll_lock: false,
compose: false,
kana: false,
shift: false,
} as KeyboardLedState,
setKeyboardLedState: (ledState: KeyboardLedState): void =>
set({ keyboardLedState: ledState }),
keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState, keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState,
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: (enabled: boolean): void =>
set({ isVirtualKeyboardEnabled: enabled }),
isPasteInProgress: false, isPasteInProgress: false,
setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }), setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }),
@ -547,9 +565,9 @@ export interface OtaState {
systemVerificationProgress: number; systemVerificationProgress: number;
systemVerifiedAt: string | null; systemVerifiedAt: string | null;
systemUpdateProgress: number; systemUpdateProgress: number;
systemUpdatedAt: string | null; systemUpdatedAt: string | null;
}; }
export interface UpdateState { export interface UpdateState {
isUpdatePending: boolean; isUpdatePending: boolean;
@ -558,7 +576,7 @@ export interface UpdateState {
otaState: OtaState; otaState: OtaState;
setOtaState: (state: OtaState) => void; setOtaState: (state: OtaState) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
modalView: UpdateModalViews modalView: UpdateModalViews;
setModalView: (view: UpdateModalViews) => void; setModalView: (view: UpdateModalViews) => void;
setUpdateErrorMessage: (errorMessage: string) => void; setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null; updateErrorMessage: string | null;
@ -595,12 +613,11 @@ export const useUpdateStore = create<UpdateState>(set => ({
modalView: "loading", modalView: "loading",
setModalView: (view: UpdateModalViews) => set({ modalView: view }), setModalView: (view: UpdateModalViews) => set({ modalView: view }),
updateErrorMessage: null, updateErrorMessage: null,
setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }), setUpdateErrorMessage: (errorMessage: string) =>
set({ updateErrorMessage: errorMessage }),
})); }));
export type UsbConfigModalViews = export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess";
| "updateUsbConfig"
| "updateUsbConfigSuccess";
export interface UsbConfigModalState { export interface UsbConfigModalState {
modalView: UsbConfigModalViews ; modalView: UsbConfigModalViews ;
@ -924,7 +941,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
} },
})); }));
export interface FailsafeModeState { export interface FailsafeModeState {

View File

@ -548,8 +548,9 @@ export default function KvmIdRoute() {
clearCandidatePairStats(); clearCandidatePairStats();
setSidebarView(null); setSidebarView(null);
setPeerConnection(null); setPeerConnection(null);
setRpcDataChannel(null);
}; };
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]); }, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView, setRpcDataChannel]);
// TURN server usage detection // TURN server usage detection
useEffect(() => { useEffect(() => {

244
ui/src/utils/jsonrpc.ts Normal file
View File

@ -0,0 +1,244 @@
import { useRTCStore } from "@/hooks/stores";
import { sleep } from "@/utils";
// JSON-RPC utility for use outside of React components
export interface JsonRpcCallOptions {
method: string;
params?: unknown;
attemptTimeoutMs?: number;
maxAttempts?: number;
}
export interface JsonRpcCallResponse<T = unknown> {
jsonrpc: string;
result?: T;
error?: {
code: number;
message: string;
data?: unknown;
};
id: number | string | null;
}
let rpcCallCounter = 0;
// Helper: wait for RTC data channel to be ready
// This waits indefinitely for the channel to be ready, only aborting via the signal
// Throws if the channel instance changed while waiting (stale connection detected)
async function waitForRtcReady(signal: AbortSignal): Promise<RTCDataChannel> {
const pollInterval = 100;
let lastSeenChannel: RTCDataChannel | null = null;
while (!signal.aborted) {
const state = useRTCStore.getState();
const currentChannel = state.rpcDataChannel;
// Channel instance changed (new connection replaced old one)
if (lastSeenChannel && currentChannel && lastSeenChannel !== currentChannel) {
console.debug("[waitForRtcReady] Channel instance changed, aborting wait");
throw new Error("RTC connection changed while waiting for readiness");
}
// Channel was removed from store (connection closed)
if (lastSeenChannel && !currentChannel) {
console.debug("[waitForRtcReady] Channel was removed from store, aborting wait");
throw new Error("RTC connection was closed while waiting for readiness");
}
// No channel yet, keep waiting
if (!currentChannel) {
await sleep(pollInterval);
continue;
}
// Track this channel instance
lastSeenChannel = currentChannel;
// Channel is ready!
if (currentChannel.readyState === "open") {
return currentChannel;
}
await sleep(pollInterval);
}
// Signal was aborted for some reason
console.debug("[waitForRtcReady] Aborted via signal");
throw new Error("RTC readiness check aborted");
}
// Helper: send RPC request and wait for response
async function sendRpcRequest<T>(
rpcDataChannel: RTCDataChannel,
options: JsonRpcCallOptions,
signal: AbortSignal,
): Promise<JsonRpcCallResponse<T>> {
return new Promise((resolve, reject) => {
rpcCallCounter++;
const requestId = `rpc_${Date.now()}_${rpcCallCounter}`;
const request = {
jsonrpc: "2.0",
method: options.method,
params: options.params || {},
id: requestId,
};
const messageHandler = (event: MessageEvent) => {
try {
const response = JSON.parse(event.data) as JsonRpcCallResponse<T>;
if (response.id === requestId) {
cleanup();
resolve(response);
}
} catch {
// Ignore parse errors from other messages
}
};
const abortHandler = () => {
cleanup();
reject(new Error("Request aborted"));
};
const cleanup = () => {
rpcDataChannel.removeEventListener("message", messageHandler);
signal.removeEventListener("abort", abortHandler);
};
signal.addEventListener("abort", abortHandler);
rpcDataChannel.addEventListener("message", messageHandler);
rpcDataChannel.send(JSON.stringify(request));
});
}
// Function overloads for better typing
export function callJsonRpc<T>(
options: JsonRpcCallOptions,
): Promise<JsonRpcCallResponse<T> & { result: T }>;
export function callJsonRpc(
options: JsonRpcCallOptions,
): Promise<JsonRpcCallResponse<unknown>>;
export async function callJsonRpc<T = unknown>(
options: JsonRpcCallOptions,
): Promise<JsonRpcCallResponse<T>> {
const maxAttempts = options.maxAttempts ?? 1;
const timeout = options.attemptTimeoutMs || 5000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Exponential backoff for retries that starts at 500ms up to a maximum of 10 seconds
const backoffMs = Math.min(500 * Math.pow(2, attempt), 10000);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
// Wait for RTC readiness without timeout - this allows time for WebRTC to connect
const readyAbortController = new AbortController();
const rpcDataChannel = await waitForRtcReady(readyAbortController.signal);
// Now apply timeout only to the actual RPC request/response
const rpcAbortController = new AbortController();
timeoutId = setTimeout(() => rpcAbortController.abort(), timeout);
// Send RPC request and wait for response
const response = await sendRpcRequest<T>(
rpcDataChannel,
options,
rpcAbortController.signal,
);
// Retry on error if attempts remain
if (response.error && attempt < maxAttempts - 1) {
await sleep(backoffMs);
continue;
}
return response;
} catch (error) {
// Retry on timeout/error if attempts remain
if (attempt < maxAttempts - 1) {
await sleep(backoffMs);
continue;
}
throw error instanceof Error
? error
: new Error(`JSON-RPC call failed after ${timeout}ms`);
} finally {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
}
}
// Should never reach here due to loop logic, but TypeScript needs this
throw new Error("Unexpected error in callJsonRpc");
}
// Specific network settings API calls
export async function getNetworkSettings() {
const response = await callJsonRpc({ method: "getNetworkSettings" });
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
}
export async function setNetworkSettings(settings: unknown) {
const response = await callJsonRpc({
method: "setNetworkSettings",
params: { settings },
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
}
export async function getNetworkState() {
const response = await callJsonRpc({ method: "getNetworkState" });
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
}
export async function renewDHCPLease() {
const response = await callJsonRpc({ method: "renewDHCPLease" });
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
}
export interface VersionInfo {
appVersion: string;
systemVersion: string;
}
export interface SystemVersionInfo {
local: VersionInfo;
remote?: VersionInfo;
systemUpdateAvailable: boolean;
appUpdateAvailable: boolean;
error?: string;
}
export async function getUpdateStatus() {
const response = await callJsonRpc<SystemVersionInfo>({
method: "getUpdateStatus",
// This function calls our api server to see if there are any updates available.
// It can be called on page load right after a restart, so we need to give it time to
// establish a connection to the api server.
maxAttempts: 6,
});
if (response.error) throw response.error;
return response.result;
}
export async function getLocalVersion() {
const response = await callJsonRpc<VersionInfo>({ method: "getLocalVersion" });
if (response.error) throw response.error;
return response.result;
}