Skip to content

Commit 5f15d8b

Browse files
refactor: More robust handling of jsonrpc calls (#915)
Co-authored-by: Marc Brooks <IDisposable@gmail.com>
1 parent 28919bf commit 5f15d8b

File tree

3 files changed

+110
-59
lines changed

3 files changed

+110
-59
lines changed

ui/src/hooks/stores.ts

Lines changed: 63 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export interface RTCState {
116116
peerConnection: RTCPeerConnection | null;
117117
setPeerConnection: (pc: RTCState["peerConnection"]) => void;
118118

119-
setRpcDataChannel: (channel: RTCDataChannel) => void;
119+
setRpcDataChannel: (channel: RTCDataChannel | null) => void;
120120
rpcDataChannel: RTCDataChannel | null;
121121

122122
hidRpcDisabled: boolean;
@@ -178,79 +178,80 @@ export const useRTCStore = create<RTCState>(set => ({
178178
setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }),
179179

180180
rpcDataChannel: null,
181-
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }),
181+
setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
182182

183183
hidRpcDisabled: false,
184-
setHidRpcDisabled: (disabled: boolean) => set({ hidRpcDisabled: disabled }),
184+
setHidRpcDisabled: disabled => set({ hidRpcDisabled: disabled }),
185185

186186
rpcHidProtocolVersion: null,
187-
setRpcHidProtocolVersion: (version: number | null) => set({ rpcHidProtocolVersion: version }),
187+
setRpcHidProtocolVersion: version => set({ rpcHidProtocolVersion: version }),
188188

189189
rpcHidChannel: null,
190-
setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }),
190+
setRpcHidChannel: channel => set({ rpcHidChannel: channel }),
191191

192192
rpcHidUnreliableChannel: null,
193-
setRpcHidUnreliableChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableChannel: channel }),
193+
setRpcHidUnreliableChannel: channel => set({ rpcHidUnreliableChannel: channel }),
194194

195195
rpcHidUnreliableNonOrderedChannel: null,
196-
setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableNonOrderedChannel: channel }),
196+
setRpcHidUnreliableNonOrderedChannel: channel =>
197+
set({ rpcHidUnreliableNonOrderedChannel: channel }),
197198

198199
transceiver: null,
199-
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }),
200+
setTransceiver: transceiver => set({ transceiver }),
200201

201202
peerConnectionState: null,
202-
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }),
203+
setPeerConnectionState: state => set({ peerConnectionState: state }),
203204

204205
mediaStream: null,
205-
setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }),
206+
setMediaStream: stream => set({ mediaStream: stream }),
206207

207208
videoStreamStats: null,
208-
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }),
209+
appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
209210
videoStreamStatsHistory: new Map(),
210211

211212
isTurnServerInUse: false,
212-
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }),
213+
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
213214

214215
inboundRtpStats: new Map(),
215-
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => {
216+
appendInboundRtpStats: stats => {
216217
set(prevState => ({
217218
inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats),
218219
}));
219220
},
220221
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
221222

222223
candidatePairStats: new Map(),
223-
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => {
224+
appendCandidatePairStats: stats => {
224225
set(prevState => ({
225226
candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
226227
}));
227228
},
228229
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
229230

230231
localCandidateStats: new Map(),
231-
appendLocalCandidateStats: (stats: RTCIceCandidateStats) => {
232+
appendLocalCandidateStats: stats => {
232233
set(prevState => ({
233234
localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats),
234235
}));
235236
},
236237

237238
remoteCandidateStats: new Map(),
238-
appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => {
239+
appendRemoteCandidateStats: stats => {
239240
set(prevState => ({
240241
remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats),
241242
}));
242243
},
243244

244245
diskDataChannelStats: new Map(),
245-
appendDiskDataChannelStats: (stats: RTCDataChannelStats) => {
246+
appendDiskDataChannelStats: stats => {
246247
set(prevState => ({
247248
diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats),
248249
}));
249250
},
250251

251252
// Add these new properties to the store implementation
252253
terminalChannel: null,
253-
setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }),
254+
setTerminalChannel: channel => set({ terminalChannel: channel }),
254255
}));
255256

256257
export interface MouseMove {
@@ -270,12 +271,20 @@ export interface MouseState {
270271
export const useMouseStore = create<MouseState>(set => ({
271272
mouseX: 0,
272273
mouseY: 0,
273-
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }),
274-
setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }),
274+
setMouseMove: move => set({ mouseMove: move }),
275+
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
275276
}));
276277

277-
export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
278-
export type HdmiErrorStates = Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">
278+
export type HdmiStates =
279+
| "ready"
280+
| "no_signal"
281+
| "no_lock"
282+
| "out_of_range"
283+
| "connecting";
284+
export type HdmiErrorStates = Extract<
285+
VideoState["hdmiState"],
286+
"no_signal" | "no_lock" | "out_of_range"
287+
>;
279288

280289
export interface HdmiState {
281290
ready: boolean;
@@ -290,10 +299,7 @@ export interface VideoState {
290299
setClientSize: (width: number, height: number) => void;
291300
setSize: (width: number, height: number) => void;
292301
hdmiState: HdmiStates;
293-
setHdmiState: (state: {
294-
ready: boolean;
295-
error?: HdmiErrorStates;
296-
}) => void;
302+
setHdmiState: (state: { ready: boolean; error?: HdmiErrorStates }) => void;
297303
}
298304

299305
export const useVideoStore = create<VideoState>(set => ({
@@ -304,7 +310,8 @@ export const useVideoStore = create<VideoState>(set => ({
304310
clientHeight: 0,
305311

306312
// The video element's client size
307-
setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }),
313+
setClientSize: (clientWidth: number, clientHeight: number) =>
314+
set({ clientWidth, clientHeight }),
308315

309316
// Resolution
310317
setSize: (width: number, height: number) => set({ width, height }),
@@ -451,13 +458,15 @@ export interface MountMediaState {
451458

452459
export const useMountMediaStore = create<MountMediaState>(set => ({
453460
remoteVirtualMediaState: null,
454-
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }),
461+
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) =>
462+
set({ remoteVirtualMediaState: state }),
455463

456464
modalView: "mode",
457465
setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }),
458466

459467
isMountMediaDialogOpen: false,
460-
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }),
468+
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) =>
469+
set({ isMountMediaDialogOpen: isOpen }),
461470

462471
uploadedFiles: [],
463472
addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) =>
@@ -474,7 +483,7 @@ export interface KeyboardLedState {
474483
compose: boolean;
475484
kana: boolean;
476485
shift: boolean; // Optional, as not all keyboards have a shift LED
477-
};
486+
}
478487

479488
export const hidKeyBufferSize = 6;
480489
export const hidErrorRollOver = 0x01;
@@ -509,14 +518,23 @@ export interface HidState {
509518
}
510519

511520
export const useHidStore = create<HidState>(set => ({
512-
keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false, shift: false } as KeyboardLedState,
513-
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
521+
keyboardLedState: {
522+
num_lock: false,
523+
caps_lock: false,
524+
scroll_lock: false,
525+
compose: false,
526+
kana: false,
527+
shift: false,
528+
} as KeyboardLedState,
529+
setKeyboardLedState: (ledState: KeyboardLedState): void =>
530+
set({ keyboardLedState: ledState }),
514531

515532
keysDownState: { modifier: 0, keys: [0, 0, 0, 0, 0, 0] } as KeysDownState,
516533
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
517534

518535
isVirtualKeyboardEnabled: false,
519-
setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }),
536+
setVirtualKeyboardEnabled: (enabled: boolean): void =>
537+
set({ isVirtualKeyboardEnabled: enabled }),
520538

521539
isPasteInProgress: false,
522540
setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }),
@@ -568,7 +586,7 @@ export interface OtaState {
568586

569587
systemUpdateProgress: number;
570588
systemUpdatedAt: string | null;
571-
};
589+
}
572590

573591
export interface UpdateState {
574592
isUpdatePending: boolean;
@@ -580,7 +598,7 @@ export interface UpdateState {
580598
otaState: OtaState;
581599
setOtaState: (state: OtaState) => void;
582600

583-
modalView: UpdateModalViews
601+
modalView: UpdateModalViews;
584602
setModalView: (view: UpdateModalViews) => void;
585603

586604
updateErrorMessage: string | null;
@@ -620,12 +638,11 @@ export const useUpdateStore = create<UpdateState>(set => ({
620638
setModalView: (view: UpdateModalViews) => set({ modalView: view }),
621639

622640
updateErrorMessage: null,
623-
setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }),
641+
setUpdateErrorMessage: (errorMessage: string) =>
642+
set({ updateErrorMessage: errorMessage }),
624643
}));
625644

626-
export type UsbConfigModalViews =
627-
| "updateUsbConfig"
628-
| "updateUsbConfigSuccess";
645+
export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess";
629646

630647
export interface UsbConfigModalState {
631648
modalView: UsbConfigModalViews;
@@ -833,12 +850,12 @@ export interface MacrosState {
833850
loadMacros: () => Promise<void>;
834851
saveMacros: (macros: KeySequence[]) => Promise<void>;
835852
sendFn:
836-
| ((
837-
method: string,
838-
params: unknown,
839-
callback?: ((resp: JsonRpcResponse) => void) | undefined,
840-
) => void)
841-
| null;
853+
| ((
854+
method: string,
855+
params: unknown,
856+
callback?: ((resp: JsonRpcResponse) => void) | undefined,
857+
) => void)
858+
| null;
842859
setSendFn: (
843860
sendFn: (
844861
method: string,
@@ -978,5 +995,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
978995
} finally {
979996
set({ loading: false });
980997
}
981-
}
998+
},
982999
}));

ui/src/routes/devices.$id.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,8 +557,9 @@ export default function KvmIdRoute() {
557557
clearCandidatePairStats();
558558
setSidebarView(null);
559559
setPeerConnection(null);
560+
setRpcDataChannel(null);
560561
};
561-
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]);
562+
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView, setRpcDataChannel]);
562563

563564
// TURN server usage detection
564565
useEffect(() => {

ui/src/utils/jsonrpc.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,47 @@ export interface JsonRpcCallResponse<T = unknown> {
2424
let rpcCallCounter = 0;
2525

2626
// Helper: wait for RTC data channel to be ready
27+
// This waits indefinitely for the channel to be ready, only aborting via the signal
28+
// Throws if the channel instance changed while waiting (stale connection detected)
2729
async function waitForRtcReady(signal: AbortSignal): Promise<RTCDataChannel> {
2830
const pollInterval = 100;
31+
let lastSeenChannel: RTCDataChannel | null = null;
2932

3033
while (!signal.aborted) {
3134
const state = useRTCStore.getState();
32-
if (state.rpcDataChannel?.readyState === "open") {
33-
return state.rpcDataChannel;
35+
const currentChannel = state.rpcDataChannel;
36+
37+
// Channel instance changed (new connection replaced old one)
38+
if (lastSeenChannel && currentChannel && lastSeenChannel !== currentChannel) {
39+
console.debug("[waitForRtcReady] Channel instance changed, aborting wait");
40+
throw new Error("RTC connection changed while waiting for readiness");
41+
}
42+
43+
// Channel was removed from store (connection closed)
44+
if (lastSeenChannel && !currentChannel) {
45+
console.debug("[waitForRtcReady] Channel was removed from store, aborting wait");
46+
throw new Error("RTC connection was closed while waiting for readiness");
47+
}
48+
49+
// No channel yet, keep waiting
50+
if (!currentChannel) {
51+
await sleep(pollInterval);
52+
continue;
53+
}
54+
55+
// Track this channel instance
56+
lastSeenChannel = currentChannel;
57+
58+
// Channel is ready!
59+
if (currentChannel.readyState === "open") {
60+
return currentChannel;
3461
}
62+
3563
await sleep(pollInterval);
3664
}
3765

66+
// Signal was aborted for some reason
67+
console.debug("[waitForRtcReady] Aborted via signal");
3868
throw new Error("RTC readiness check aborted");
3969
}
4070

@@ -97,25 +127,26 @@ export async function callJsonRpc<T = unknown>(
97127
const timeout = options.attemptTimeoutMs || 5000;
98128

99129
for (let attempt = 0; attempt < maxAttempts; attempt++) {
100-
const abortController = new AbortController();
101-
const timeoutId = setTimeout(() => abortController.abort(), timeout);
102-
103130
// Exponential backoff for retries that starts at 500ms up to a maximum of 10 seconds
104131
const backoffMs = Math.min(500 * Math.pow(2, attempt), 10000);
132+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
105133

106134
try {
107-
// Wait for RTC readiness
108-
const rpcDataChannel = await waitForRtcReady(abortController.signal);
135+
// Wait for RTC readiness without timeout - this allows time for WebRTC to connect
136+
const readyAbortController = new AbortController();
137+
const rpcDataChannel = await waitForRtcReady(readyAbortController.signal);
138+
139+
// Now apply timeout only to the actual RPC request/response
140+
const rpcAbortController = new AbortController();
141+
timeoutId = setTimeout(() => rpcAbortController.abort(), timeout);
109142

110143
// Send RPC request and wait for response
111144
const response = await sendRpcRequest<T>(
112145
rpcDataChannel,
113146
options,
114-
abortController.signal,
147+
rpcAbortController.signal,
115148
);
116149

117-
clearTimeout(timeoutId);
118-
119150
// Retry on error if attempts remain
120151
if (response.error && attempt < maxAttempts - 1) {
121152
await sleep(backoffMs);
@@ -124,8 +155,6 @@ export async function callJsonRpc<T = unknown>(
124155

125156
return response;
126157
} catch (error) {
127-
clearTimeout(timeoutId);
128-
129158
// Retry on timeout/error if attempts remain
130159
if (attempt < maxAttempts - 1) {
131160
await sleep(backoffMs);
@@ -135,6 +164,10 @@ export async function callJsonRpc<T = unknown>(
135164
throw error instanceof Error
136165
? error
137166
: new Error(`JSON-RPC call failed after ${timeout}ms`);
167+
} finally {
168+
if (timeoutId !== null) {
169+
clearTimeout(timeoutId);
170+
}
138171
}
139172
}
140173

0 commit comments

Comments
 (0)