summaryrefslogtreecommitdiff
path: root/frontend/src/wsrouter.ts
diff options
context:
space:
mode:
authorLukas Senionis <warliukz@gmail.com>2026-07-14 05:02:13 +0300
committerGitHub <noreply@github.com>2026-07-13 22:02:13 -0400
commit91abff7811c6db3a80c9426c0c9a0a247a3ada37 (patch)
treeee5f4899214d6b1f618764f151385761421a8829 /frontend/src/wsrouter.ts
parent69475d9a0fbd4cb4a4e1fb257dff13385f2827a7 (diff)
downloaddecky-loader-91abff7811c6db3a80c9426c0c9a0a247a3ada37.tar.gz
decky-loader-91abff7811c6db3a80c9426c0c9a0a247a3ada37.zip
fix(wsrouter): ensure no message reply is lost (#906)
Diffstat (limited to 'frontend/src/wsrouter.ts')
-rw-r--r--frontend/src/wsrouter.ts86
1 files changed, 64 insertions, 22 deletions
diff --git a/frontend/src/wsrouter.ts b/frontend/src/wsrouter.ts
index 00d0bd2e..5d02fc0c 100644
--- a/frontend/src/wsrouter.ts
+++ b/frontend/src/wsrouter.ts
@@ -8,11 +8,16 @@ declare global {
enum MessageType {
ERROR = -1,
- // Call-reply, Frontend -> Backend -> Frontend
+ // Call-reply, Frontend (CALL) -> Backend (REPLY|DISCARD) -> Frontend (RECEIVED_RESPONSE) -> Backend
CALL = 0,
REPLY = 1,
+ DISCARD = 2,
+ RECEIVED_RESPONSE = 3,
+ // Call-reply sync on connection lost,
+ // Frontend (FULL_SYNC) -> Backend (REPLY|DISCARD...) -> Frontend (RECEIVED_RESPONSE...) -> Backend
+ FULL_SYNC = 4,
// Pub/Sub, Backend -> Frontend
- EVENT = 3,
+ EVENT = 5,
}
interface CallMessage {
@@ -28,6 +33,21 @@ interface ReplyMessage {
id: number;
}
+interface DiscardMessage {
+ type: MessageType.DISCARD;
+ id: number;
+}
+
+interface ReceivedResponseMessage {
+ type: MessageType.RECEIVED_RESPONSE;
+ id: number;
+}
+
+interface FullSyncMessage {
+ type: MessageType.FULL_SYNC;
+ messages: CallMessage[];
+}
+
interface ErrorMessage {
type: MessageType.ERROR;
error: { name: string; error: string; traceback: string | null };
@@ -58,20 +78,21 @@ interface EventMessage {
args: any;
}
-type Message = CallMessage | ReplyMessage | ErrorMessage | EventMessage;
+type MessageToBackend = CallMessage | ReceivedResponseMessage | FullSyncMessage;
+type MessageFromBackend = ReplyMessage | ErrorMessage | DiscardMessage | EventMessage;
// Helper to resolve a promise from the outside
interface PromiseResolver<T> {
resolve: (res: T) => void;
reject: (error: PyError) => void;
promise: Promise<T>;
+ message: CallMessage;
}
export class WSRouter extends Logger {
runningCalls: Map<number, PromiseResolver<any>> = new Map();
eventListeners: Map<string, Set<(...args: any) => any>> = new Map();
ws?: WebSocket;
- connectPromise?: Promise<void>;
// Used to map results and errors to calls
reqId: number = 0;
constructor() {
@@ -79,23 +100,26 @@ export class WSRouter extends Logger {
}
connect() {
- return (this.connectPromise = new Promise<void>((resolve) => {
+ return new Promise<void>((resolve) => {
// Auth is a query param as JS WebSocket doesn't support headers
this.ws = new WebSocket(`ws://127.0.0.1:1337/ws?auth=${deckyAuthToken}`);
this.ws.addEventListener('open', () => {
this.debug('WS Connected');
resolve();
- delete this.connectPromise;
+
+ // Synchronize frontend and backend by forwarding all the running calls again
+ // and letting backend reply to them accordingly.
+ const messages = Array.from(this.runningCalls.values()).map((resolver) => resolver.message);
+ this.write({ type: MessageType.FULL_SYNC, messages });
});
this.ws.addEventListener('message', this.onMessage.bind(this));
this.ws.addEventListener('close', this.onError.bind(this));
- // this.ws.addEventListener('error', this.onError.bind(this));
- }));
+ });
}
- createPromiseResolver<T>(): PromiseResolver<T> {
- let resolver: Partial<PromiseResolver<T>> = {};
+ createPromiseResolver<T>(message: CallMessage): PromiseResolver<T> {
+ let resolver: Partial<PromiseResolver<T>> = { message };
const promise = new Promise<T>((resolve, reject) => {
resolver.resolve = resolve;
resolver.reject = reject;
@@ -104,11 +128,6 @@ export class WSRouter extends Logger {
return resolver as PromiseResolver<T>;
}
- async write(data: Message) {
- if (this.connectPromise) await this.connectPromise;
- this.ws?.send(JSON.stringify(data));
- }
-
addEventListener(event: string, listener: (...args: any) => any) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set([listener]));
@@ -130,7 +149,7 @@ export class WSRouter extends Logger {
async onMessage(msg: MessageEvent) {
try {
- const data = JSON.parse(msg.data) as Message;
+ const data = JSON.parse(msg.data) as MessageFromBackend;
switch (data.type) {
case MessageType.REPLY:
if (this.runningCalls.has(data.id)) {
@@ -138,6 +157,8 @@ export class WSRouter extends Logger {
this.runningCalls.delete(data.id);
this.debug(`[${data.id}] Resolved PY call with value`, data.result);
}
+
+ this.write({ type: MessageType.RECEIVED_RESPONSE, id: data.id });
break;
case MessageType.ERROR:
@@ -147,6 +168,21 @@ export class WSRouter extends Logger {
this.runningCalls.delete(data.id);
this.debug(`[${data.id}] Rejected PY call with error`, data.error);
}
+
+ this.write({ type: MessageType.RECEIVED_RESPONSE, id: data.id });
+ break;
+
+ case MessageType.DISCARD:
+ if (this.runningCalls.has(data.id)) {
+ // We are explicitly not resolving the promise here as this message
+ // is only received (at the moment) when plugin is being unloaded and
+ // the promise should be garbage-collected, unless the plugin is doing
+ // very bad things!
+ this.runningCalls.delete(data.id);
+ this.debug(`[${data.id}] Discarding PY call`);
+ }
+
+ this.write({ type: MessageType.RECEIVED_RESPONSE, id: data.id });
break;
case MessageType.EVENT:
@@ -177,15 +213,12 @@ export class WSRouter extends Logger {
// this.call<[number, number], string>('methodName', 1, 2);
call<Args extends any[] = [], Return = void>(route: string, ...args: Args): Promise<Return> {
- const resolver = this.createPromiseResolver<Return>();
-
const id = ++this.reqId;
-
+ const resolver = this.createPromiseResolver<Return>({ type: MessageType.CALL, route, args, id });
this.runningCalls.set(id, resolver);
this.debug(`[${id}] Calling PY method ${route} with args`, args);
-
- this.write({ type: MessageType.CALL, route, args, id });
+ this.write(resolver.message);
return resolver.promise;
}
@@ -196,8 +229,17 @@ export class WSRouter extends Logger {
async onError(error: any) {
this.error('WS DISCONNECTED', error);
- // TODO queue up lost messages and send them once we connect again
await sleep(5000);
await this.connect();
}
+
+ private write(data: MessageToBackend) {
+ // In case the message is discarded here:
+ // - CallMessage will be resent during FULL_SYNC
+ // - Backend cleanup via missed ReceivedReplyMessage will be done during FULL_SYNC
+ // - FullSyncMessage will be resent during FULL_SYNC
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws?.send(JSON.stringify(data));
+ }
+ }
}