refactor: enhance socket service robustness with locks and error handling
fix: add null checks and safe access utilities to prevent crashes refactor: restructure socket methods for better concurrency and logging
This commit is contained in:
parent
70a405b231
commit
b710008dbe
@ -8,6 +8,14 @@
|
||||
|
||||
|
||||
|
||||
|
||||
## Version 1.8.0 - 2025-12-18
|
||||
|
||||
The socket service now handles errors more robustly and prevents crashes through improved safety checks. Socket methods support better concurrency and provide enhanced logging for developers.
|
||||
|
||||
**Changes:** 4 files, 2279 lines
|
||||
**Languages:** JavaScript (592 lines), Python (1687 lines)
|
||||
|
||||
## Version 1.7.0 - 2025-12-17
|
||||
|
||||
Fixes socket cleanup in the websocket handler to prevent resource leaks and improve connection stability.
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "Snek"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
readme = "README.md"
|
||||
#license = { file = "LICENSE", content-type="text/markdown" }
|
||||
description = "Snek Chat Application by Molodetz"
|
||||
|
||||
@ -1,12 +1,34 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from snek.model.user import UserModel
|
||||
from snek.system.service import BaseService
|
||||
from snek.system.model import now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from snek.system.model import now
|
||||
|
||||
|
||||
def safe_get(obj, key, default=None):
|
||||
if obj is None:
|
||||
return default
|
||||
try:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def safe_str(obj):
|
||||
if obj is None:
|
||||
return ""
|
||||
try:
|
||||
return str(obj)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
class SocketService(BaseService):
|
||||
|
||||
@ -15,28 +37,43 @@ class SocketService(BaseService):
|
||||
self.ws = ws
|
||||
self.is_connected = True
|
||||
self.user = user
|
||||
self.user_uid = user["uid"] if user else None
|
||||
self.user_color = user["color"] if user else None
|
||||
self.user_uid = safe_get(user, "uid") if user else None
|
||||
self.user_color = safe_get(user, "color") if user else None
|
||||
self._lock = asyncio.Lock()
|
||||
self.subscribed_channels = set()
|
||||
|
||||
async def send_json(self, data):
|
||||
if not self.is_connected:
|
||||
return False
|
||||
try:
|
||||
await self.ws.send_json(data)
|
||||
except Exception:
|
||||
if not self.ws:
|
||||
self.is_connected = False
|
||||
return self.is_connected
|
||||
return False
|
||||
if data is None:
|
||||
return False
|
||||
async with self._lock:
|
||||
try:
|
||||
await self.ws.send_json(data)
|
||||
return True
|
||||
except ConnectionResetError:
|
||||
self.is_connected = False
|
||||
logger.debug("Connection reset during send_json")
|
||||
except Exception as ex:
|
||||
self.is_connected = False
|
||||
logger.debug(f"send_json failed: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def close(self):
|
||||
if not self.is_connected:
|
||||
return True
|
||||
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.is_connected = False
|
||||
|
||||
async with self._lock:
|
||||
try:
|
||||
if self.ws:
|
||||
await self.ws.close()
|
||||
except Exception as ex:
|
||||
logger.debug(f"Socket close failed: {safe_str(ex)}")
|
||||
finally:
|
||||
self.is_connected = False
|
||||
self.subscribed_channels.clear()
|
||||
return True
|
||||
|
||||
def __init__(self, app):
|
||||
@ -45,124 +82,245 @@ class SocketService(BaseService):
|
||||
self.users = {}
|
||||
self.subscriptions = {}
|
||||
self.last_update = str(datetime.now())
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def user_availability_service(self):
|
||||
logger.info("User availability update service started.")
|
||||
logger.debug("Entering the main loop.")
|
||||
while True:
|
||||
logger.info("Updating user availability...")
|
||||
logger.debug("Initializing users_updated list.")
|
||||
users_updated = []
|
||||
logger.debug("Iterating over sockets.")
|
||||
for s in self.sockets:
|
||||
logger.debug(f"Checking connection status for socket: {s}.")
|
||||
if not s.is_connected:
|
||||
logger.debug("Socket is not connected, continuing to next socket.")
|
||||
continue
|
||||
logger.debug(f"Checking if user {s.user} is already updated.")
|
||||
if s.user not in users_updated:
|
||||
logger.debug(f"Updating last_ping for user: {s.user}.")
|
||||
s.user["last_ping"] = now()
|
||||
logger.debug(f"Saving user {s.user} to the database.")
|
||||
await self.app.services.user.save(s.user)
|
||||
logger.debug(f"Adding user {s.user} to users_updated list.")
|
||||
users_updated.append(s.user)
|
||||
logger.info(
|
||||
f"Updated user availability for {len(users_updated)} online users."
|
||||
)
|
||||
logger.debug("Sleeping for 60 seconds before the next update.")
|
||||
await asyncio.sleep(60)
|
||||
try:
|
||||
users_updated = []
|
||||
sockets_copy = list(self.sockets)
|
||||
for s in sockets_copy:
|
||||
try:
|
||||
if not s or not s.is_connected:
|
||||
continue
|
||||
if not s.user:
|
||||
continue
|
||||
if s.user in users_updated:
|
||||
continue
|
||||
s.user["last_ping"] = now()
|
||||
if self.app and hasattr(self.app, "services") and self.app.services:
|
||||
await self.app.services.user.save(s.user)
|
||||
users_updated.append(s.user)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to update user availability: {safe_str(ex)}")
|
||||
logger.info(f"Updated user availability for {len(users_updated)} online users.")
|
||||
except Exception as ex:
|
||||
logger.warning(f"User availability service error: {safe_str(ex)}")
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("User availability service cancelled")
|
||||
break
|
||||
|
||||
async def add(self, ws, user_uid):
|
||||
if not ws:
|
||||
return None
|
||||
if not user_uid:
|
||||
return None
|
||||
user = await self.app.services.user.get(uid=user_uid)
|
||||
if not user:
|
||||
try:
|
||||
if not self.app or not hasattr(self.app, "services") or not self.app.services:
|
||||
logger.warning("Services not available for socket add")
|
||||
return None
|
||||
user = await self.app.services.user.get(uid=user_uid)
|
||||
if not user:
|
||||
logger.warning(f"User not found for socket add: {user_uid}")
|
||||
return None
|
||||
s = self.Socket(ws, user)
|
||||
async with self._lock:
|
||||
self.sockets.add(s)
|
||||
try:
|
||||
s.user["last_ping"] = now()
|
||||
await self.app.services.user.save(s.user)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to update last_ping: {safe_str(ex)}")
|
||||
username = safe_get(s.user, "username", "unknown")
|
||||
logger.info(f"Added socket for user {username}")
|
||||
is_first_connection = False
|
||||
async with self._lock:
|
||||
if user_uid not in self.users:
|
||||
self.users[user_uid] = set()
|
||||
is_first_connection = True
|
||||
elif len(self.users[user_uid]) == 0:
|
||||
is_first_connection = True
|
||||
self.users[user_uid].add(s)
|
||||
if is_first_connection:
|
||||
nick = safe_get(s.user, "nick") or safe_get(s.user, "username", "")
|
||||
color = s.user_color
|
||||
await self._broadcast_presence("arrived", user_uid, nick, color)
|
||||
return s
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to add socket: {safe_str(ex)}")
|
||||
return None
|
||||
s = self.Socket(ws, user)
|
||||
self.sockets.add(s)
|
||||
s.user["last_ping"] = now()
|
||||
await self.app.services.user.save(s.user)
|
||||
logger.info(f"Added socket for user {s.user['username']}")
|
||||
|
||||
is_first_connection = False
|
||||
if not self.users.get(user_uid):
|
||||
self.users[user_uid] = set()
|
||||
is_first_connection = True
|
||||
elif len(self.users[user_uid]) == 0:
|
||||
is_first_connection = True
|
||||
self.users[user_uid].add(s)
|
||||
|
||||
if is_first_connection:
|
||||
await self._broadcast_presence("arrived", user_uid, s.user["nick"] or s.user["username"], s.user_color)
|
||||
|
||||
return s
|
||||
|
||||
async def subscribe(self, ws, channel_uid, user_uid):
|
||||
if channel_uid not in self.subscriptions:
|
||||
self.subscriptions[channel_uid] = set()
|
||||
s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))
|
||||
self.subscriptions[channel_uid].add(s)
|
||||
if not ws or not channel_uid or not user_uid:
|
||||
return False
|
||||
try:
|
||||
async with self._lock:
|
||||
existing_socket = None
|
||||
for sock in self.sockets:
|
||||
if sock and sock.ws == ws and sock.user_uid == user_uid:
|
||||
existing_socket = sock
|
||||
break
|
||||
if not existing_socket:
|
||||
return False
|
||||
existing_socket.subscribed_channels.add(channel_uid)
|
||||
if channel_uid not in self.subscriptions:
|
||||
self.subscriptions[channel_uid] = set()
|
||||
self.subscriptions[channel_uid].add(user_uid)
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to subscribe: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def send_to_user(self, user_uid, message):
|
||||
if not user_uid or message is None:
|
||||
return 0
|
||||
count = 0
|
||||
for s in list(self.users.get(user_uid, [])):
|
||||
if await s.send_json(message):
|
||||
count += 1
|
||||
try:
|
||||
async with self._lock:
|
||||
user_sockets = list(self.users.get(user_uid, []))
|
||||
for s in user_sockets:
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
if await s.send_json(message):
|
||||
count += 1
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to send to user socket: {safe_str(ex)}")
|
||||
except Exception as ex:
|
||||
logger.warning(f"send_to_user failed: {safe_str(ex)}")
|
||||
return count
|
||||
|
||||
async def broadcast(self, channel_uid, message):
|
||||
await self._broadcast(channel_uid, message)
|
||||
if not channel_uid or message is None:
|
||||
return False
|
||||
return await self._broadcast(channel_uid, message)
|
||||
|
||||
async def _broadcast(self, channel_uid, message):
|
||||
if not channel_uid or message is None:
|
||||
return False
|
||||
sent = 0
|
||||
user_uids_to_send = set()
|
||||
try:
|
||||
async for user_uid in self.services.channel_member.get_user_uids(
|
||||
channel_uid
|
||||
):
|
||||
sent += await self.send_to_user(user_uid, message)
|
||||
if self.services:
|
||||
try:
|
||||
async for user_uid in self.services.channel_member.get_user_uids(channel_uid):
|
||||
if user_uid:
|
||||
user_uids_to_send.add(user_uid)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Broadcast db query failed: {safe_str(ex)}")
|
||||
if not user_uids_to_send:
|
||||
async with self._lock:
|
||||
if channel_uid in self.subscriptions:
|
||||
user_uids_to_send = set(self.subscriptions[channel_uid])
|
||||
for user_uid in user_uids_to_send:
|
||||
try:
|
||||
sent += await self.send_to_user(user_uid, message)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to send to user {user_uid}: {safe_str(ex)}")
|
||||
logger.debug(f"Broadcasted a message to {sent} users.")
|
||||
return True
|
||||
except Exception as ex:
|
||||
print(ex, flush=True)
|
||||
logger.info(f"Broadcasted a message to {sent} users.")
|
||||
return True
|
||||
logger.warning(f"Broadcast failed: {safe_str(ex)}")
|
||||
return False
|
||||
|
||||
async def delete(self, ws):
|
||||
for s in [sock for sock in self.sockets if sock.ws == ws]:
|
||||
await s.close()
|
||||
user_uid = s.user_uid
|
||||
user_nick = (s.user["nick"] or s.user["username"]) if s.user else None
|
||||
user_color = s.user_color
|
||||
logger.info(f"Removed socket for user {s.user['username'] if s.user else 'unknown'}")
|
||||
self.sockets.discard(s)
|
||||
|
||||
if user_uid:
|
||||
if user_uid in self.users:
|
||||
if not ws:
|
||||
return
|
||||
sockets_to_remove = []
|
||||
departures_to_broadcast = []
|
||||
async with self._lock:
|
||||
sockets_to_remove = [sock for sock in self.sockets if sock and sock.ws == ws]
|
||||
for s in sockets_to_remove:
|
||||
self.sockets.discard(s)
|
||||
user_uid = s.user_uid
|
||||
if user_uid and user_uid in self.users:
|
||||
self.users[user_uid].discard(s)
|
||||
if len(self.users[user_uid]) == 0:
|
||||
await self._broadcast_presence("departed", user_uid, user_nick, user_color)
|
||||
del self.users[user_uid]
|
||||
user_nick = None
|
||||
try:
|
||||
if s.user:
|
||||
user_nick = safe_get(s.user, "nick") or safe_get(s.user, "username")
|
||||
except Exception:
|
||||
pass
|
||||
if user_nick:
|
||||
departures_to_broadcast.append((user_uid, user_nick, s.user_color))
|
||||
for channel_uid in list(s.subscribed_channels):
|
||||
if channel_uid in self.subscriptions:
|
||||
self.subscriptions[channel_uid].discard(user_uid)
|
||||
if len(self.subscriptions[channel_uid]) == 0:
|
||||
del self.subscriptions[channel_uid]
|
||||
for s in sockets_to_remove:
|
||||
try:
|
||||
username = safe_get(s.user, "username", "unknown") if s.user else "unknown"
|
||||
logger.info(f"Removed socket for user {username}")
|
||||
await s.close()
|
||||
except Exception as ex:
|
||||
logger.warning(f"Socket close failed: {safe_str(ex)}")
|
||||
for user_uid, user_nick, user_color in departures_to_broadcast:
|
||||
try:
|
||||
await self._broadcast_presence("departed", user_uid, user_nick, user_color)
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to broadcast departure: {safe_str(ex)}")
|
||||
|
||||
async def _broadcast_presence(self, event_type, user_uid, user_nick, user_color):
|
||||
if not user_uid or not user_nick:
|
||||
return
|
||||
message = {
|
||||
"event": "user_presence",
|
||||
"data": {
|
||||
"type": event_type,
|
||||
"user_uid": user_uid,
|
||||
"user_nick": user_nick,
|
||||
"user_color": user_color,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
if not event_type or event_type not in ("arrived", "departed"):
|
||||
return
|
||||
try:
|
||||
message = {
|
||||
"event": "user_presence",
|
||||
"data": {
|
||||
"type": event_type,
|
||||
"user_uid": user_uid,
|
||||
"user_nick": user_nick,
|
||||
"user_color": user_color,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
}
|
||||
}
|
||||
sent_count = 0
|
||||
for s in list(self.sockets):
|
||||
if not s.is_connected:
|
||||
continue
|
||||
if s.user_uid == user_uid:
|
||||
continue
|
||||
try:
|
||||
if await s.send_json(message):
|
||||
sent_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"Broadcast presence '{event_type}' for {user_nick} to {sent_count} users")
|
||||
sent_count = 0
|
||||
async with self._lock:
|
||||
sockets_copy = list(self.sockets)
|
||||
for s in sockets_copy:
|
||||
if not s or not s.is_connected:
|
||||
continue
|
||||
if s.user_uid == user_uid:
|
||||
continue
|
||||
try:
|
||||
if await s.send_json(message):
|
||||
sent_count += 1
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to send presence to socket: {safe_str(ex)}")
|
||||
logger.info(f"Broadcast presence '{event_type}' for {user_nick} to {sent_count} users")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Broadcast presence failed: {safe_str(ex)}")
|
||||
|
||||
async def get_connected_users(self):
|
||||
try:
|
||||
async with self._lock:
|
||||
return list(self.users.keys())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def get_user_socket_count(self, user_uid):
|
||||
if not user_uid:
|
||||
return 0
|
||||
try:
|
||||
async with self._lock:
|
||||
return len(self.users.get(user_uid, []))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def is_user_online(self, user_uid):
|
||||
if not user_uid:
|
||||
return False
|
||||
try:
|
||||
async with self._lock:
|
||||
user_sockets = self.users.get(user_uid, set())
|
||||
return any(s.is_connected for s in user_sockets if s)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@ -1,33 +1,178 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class EventHandler {
|
||||
constructor() {
|
||||
this.subscribers = {};
|
||||
this._maxListeners = 100;
|
||||
this._warnOnMaxListeners = true;
|
||||
}
|
||||
|
||||
addEventListener(type, handler, { once = false } = {}) {
|
||||
if (!this.subscribers[type]) this.subscribers[type] = [];
|
||||
if (once) {
|
||||
const originalHandler = handler;
|
||||
handler = (...args) => {
|
||||
originalHandler(...args);
|
||||
this.removeEventListener(type, handler);
|
||||
};
|
||||
addEventListener(type, handler, options = {}) {
|
||||
if (!type || typeof type !== "string") {
|
||||
console.warn("EventHandler: Invalid event type");
|
||||
return false;
|
||||
}
|
||||
if (!handler || typeof handler !== "function") {
|
||||
console.warn("EventHandler: Invalid handler");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const once = options && options.once === true;
|
||||
if (!this.subscribers[type]) {
|
||||
this.subscribers[type] = [];
|
||||
}
|
||||
if (this._warnOnMaxListeners && this.subscribers[type].length >= this._maxListeners) {
|
||||
console.warn(`EventHandler: Max listeners (${this._maxListeners}) reached for event "${type}"`);
|
||||
}
|
||||
let wrappedHandler = handler;
|
||||
if (once) {
|
||||
const originalHandler = handler;
|
||||
wrappedHandler = (...args) => {
|
||||
try {
|
||||
originalHandler(...args);
|
||||
} catch (e) {
|
||||
console.error(`EventHandler: Error in once handler for "${type}":`, e);
|
||||
} finally {
|
||||
this.removeEventListener(type, wrappedHandler);
|
||||
}
|
||||
};
|
||||
wrappedHandler._original = originalHandler;
|
||||
}
|
||||
this.subscribers[type].push(wrappedHandler);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("EventHandler: addEventListener error:", e);
|
||||
return false;
|
||||
}
|
||||
this.subscribers[type].push(handler);
|
||||
}
|
||||
|
||||
emit(type, ...data) {
|
||||
if (this.subscribers[type])
|
||||
this.subscribers[type].forEach((handler) => handler(...data));
|
||||
if (!type || typeof type !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const handlers = this.subscribers[type];
|
||||
if (!handlers || !Array.isArray(handlers) || handlers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const handlersCopy = [...handlers];
|
||||
for (const handler of handlersCopy) {
|
||||
if (typeof handler !== "function") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
handler(...data);
|
||||
} catch (e) {
|
||||
console.error(`EventHandler: Error in handler for "${type}":`, e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("EventHandler: emit error:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListener(type, handler) {
|
||||
if (!this.subscribers[type]) return;
|
||||
this.subscribers[type] = this.subscribers[type].filter(
|
||||
(h) => h !== handler
|
||||
);
|
||||
|
||||
if (this.subscribers[type].length === 0) {
|
||||
delete this.subscribers[type];
|
||||
if (!type || typeof type !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!this.subscribers[type]) {
|
||||
return false;
|
||||
}
|
||||
if (!handler) {
|
||||
delete this.subscribers[type];
|
||||
return true;
|
||||
}
|
||||
const originalLength = this.subscribers[type].length;
|
||||
this.subscribers[type] = this.subscribers[type].filter((h) => {
|
||||
if (h === handler) return false;
|
||||
if (h._original && h._original === handler) return false;
|
||||
return true;
|
||||
});
|
||||
if (this.subscribers[type].length === 0) {
|
||||
delete this.subscribers[type];
|
||||
}
|
||||
return this.subscribers[type] ? this.subscribers[type].length < originalLength : true;
|
||||
} catch (e) {
|
||||
console.error("EventHandler: removeEventListener error:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
removeAllEventListeners(type) {
|
||||
try {
|
||||
if (type) {
|
||||
if (this.subscribers[type]) {
|
||||
delete this.subscribers[type];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.subscribers = {};
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("EventHandler: removeAllEventListeners error:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hasEventListener(type, handler) {
|
||||
if (!type || typeof type !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!this.subscribers[type]) {
|
||||
return false;
|
||||
}
|
||||
if (!handler) {
|
||||
return this.subscribers[type].length > 0;
|
||||
}
|
||||
return this.subscribers[type].some((h) => {
|
||||
if (h === handler) return true;
|
||||
if (h._original && h._original === handler) return true;
|
||||
return false;
|
||||
});
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getEventListenerCount(type) {
|
||||
try {
|
||||
if (type) {
|
||||
return this.subscribers[type] ? this.subscribers[type].length : 0;
|
||||
}
|
||||
let count = 0;
|
||||
for (const key in this.subscribers) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.subscribers, key)) {
|
||||
count += this.subscribers[key].length;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
getEventTypes() {
|
||||
try {
|
||||
return Object.keys(this.subscribers);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
once(type, handler) {
|
||||
return this.addEventListener(type, handler, { once: true });
|
||||
}
|
||||
|
||||
off(type, handler) {
|
||||
return this.removeEventListener(type, handler);
|
||||
}
|
||||
|
||||
on(type, handler) {
|
||||
return this.addEventListener(type, handler);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,110 +1,270 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { EventHandler } from "./event-handler.js";
|
||||
|
||||
function createPromiseWithResolvers() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject, resolved: false };
|
||||
}
|
||||
|
||||
export class Socket extends EventHandler {
|
||||
/**
|
||||
* @type {URL}
|
||||
*/
|
||||
url;
|
||||
/**
|
||||
* @type {WebSocket|null}
|
||||
*/
|
||||
url = null;
|
||||
ws = null;
|
||||
|
||||
/**
|
||||
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
|
||||
*/
|
||||
connection = null;
|
||||
|
||||
shouldReconnect = true;
|
||||
|
||||
_debug = false;
|
||||
_reconnectAttempts = 0;
|
||||
_maxReconnectAttempts = 50;
|
||||
_reconnectDelay = 4000;
|
||||
_pendingCalls = new Map();
|
||||
_callTimeout = 30000;
|
||||
_isDestroyed = false;
|
||||
|
||||
get isConnected() {
|
||||
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
try {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
get isConnecting() {
|
||||
return this.ws && this.ws.readyState === WebSocket.CONNECTING;
|
||||
try {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.CONNECTING;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.url = new URL("/rpc.ws", window.location.origin);
|
||||
this.url.protocol = this.url.protocol.replace("http", "ws");
|
||||
|
||||
this.connect();
|
||||
try {
|
||||
this.url = new URL("/rpc.ws", window.location.origin);
|
||||
this.url.protocol = this.url.protocol.replace("http", "ws");
|
||||
this.connect();
|
||||
} catch (e) {
|
||||
console.error("Socket initialization failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws) {
|
||||
return this.connection.promise;
|
||||
if (this._isDestroyed) {
|
||||
return Promise.reject(new Error("Socket destroyed"));
|
||||
}
|
||||
|
||||
if (!this.connection || this.connection.resolved) {
|
||||
this.connection = Promise.withResolvers();
|
||||
if (this.ws && (this.isConnected || this.isConnecting)) {
|
||||
return this.connection ? this.connection.promise : Promise.resolve(this);
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.addEventListener("open", () => {
|
||||
this.connection.resolved = true;
|
||||
this.connection.resolve(this);
|
||||
this.emit("connected");
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
console.log("Connection closed");
|
||||
this.disconnect();
|
||||
});
|
||||
this.ws.addEventListener("error", (e) => {
|
||||
console.error("Connection error", e);
|
||||
this.disconnect();
|
||||
});
|
||||
this.ws.addEventListener("message", (e) => {
|
||||
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
|
||||
console.error("Binary data not supported");
|
||||
} else {
|
||||
try {
|
||||
this.onData(JSON.parse(e.data));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse message", e);
|
||||
}
|
||||
try {
|
||||
this._cleanup();
|
||||
if (!this.connection || this.connection.resolved) {
|
||||
this.connection = createPromiseWithResolvers();
|
||||
}
|
||||
});
|
||||
if (!this.url) {
|
||||
this.connection.reject(new Error("URL not initialized"));
|
||||
return this.connection.promise;
|
||||
}
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.addEventListener("open", () => {
|
||||
try {
|
||||
this._reconnectAttempts = 0;
|
||||
if (this.connection && !this.connection.resolved) {
|
||||
this.connection.resolved = true;
|
||||
this.connection.resolve(this);
|
||||
}
|
||||
this.emit("connected");
|
||||
} catch (e) {
|
||||
console.error("Open handler error:", e);
|
||||
}
|
||||
});
|
||||
this.ws.addEventListener("close", (event) => {
|
||||
try {
|
||||
const reason = event.reason || "Connection closed";
|
||||
console.log("Connection closed:", reason);
|
||||
this._handleDisconnect();
|
||||
} catch (e) {
|
||||
console.error("Close handler error:", e);
|
||||
}
|
||||
});
|
||||
this.ws.addEventListener("error", (e) => {
|
||||
try {
|
||||
console.error("Connection error:", e);
|
||||
this._handleDisconnect();
|
||||
} catch (ex) {
|
||||
console.error("Error handler error:", ex);
|
||||
}
|
||||
});
|
||||
this.ws.addEventListener("message", (e) => {
|
||||
this._handleMessage(e);
|
||||
});
|
||||
return this.connection.promise;
|
||||
} catch (e) {
|
||||
console.error("Connect failed:", e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
_handleMessage(e) {
|
||||
if (!e || !e.data) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
|
||||
console.warn("Binary data not supported");
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch (parseError) {
|
||||
console.error("Failed to parse message:", parseError);
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
this.onData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Message handling error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
onData(data) {
|
||||
if (data.success !== undefined && !data.success) {
|
||||
console.error(data);
|
||||
if (!data || typeof data !== "object") {
|
||||
return;
|
||||
}
|
||||
if (data.callId) {
|
||||
this.emit(data.callId, data.data);
|
||||
try {
|
||||
if (data.success !== undefined && !data.success) {
|
||||
console.error("RPC error:", data);
|
||||
}
|
||||
if (data.callId) {
|
||||
try {
|
||||
const response = {
|
||||
data: data.data,
|
||||
success: data.success !== false,
|
||||
error: data.success === false ? (data.data && data.data.error ? data.data.error : "Unknown error") : null
|
||||
};
|
||||
this.emit(data.callId, response);
|
||||
if (this._pendingCalls.has(data.callId)) {
|
||||
clearTimeout(this._pendingCalls.get(data.callId));
|
||||
this._pendingCalls.delete(data.callId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("CallId emit error:", e);
|
||||
}
|
||||
}
|
||||
if (data.channel_uid) {
|
||||
try {
|
||||
this.emit(data.channel_uid, data.data);
|
||||
if (!data.event) {
|
||||
this.emit("channel-message", data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Channel emit error:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.emit("data", data.data);
|
||||
} catch (e) {
|
||||
console.error("Data emit error:", e);
|
||||
}
|
||||
if (data.event) {
|
||||
try {
|
||||
if (this._debug) {
|
||||
console.info([data.event, data.data]);
|
||||
}
|
||||
this.emit(data.event, data.data);
|
||||
} catch (e) {
|
||||
console.error("Event emit error:", e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("onData error:", error);
|
||||
}
|
||||
if (data.channel_uid) {
|
||||
this.emit(data.channel_uid, data.data);
|
||||
if (!data["event"]) this.emit("channel-message", data);
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
try {
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Cleanup error:", e);
|
||||
}
|
||||
this.emit("data", data.data);
|
||||
if (data["event"]) {
|
||||
console.info([data.event,data.data])
|
||||
this.emit(data.event, data.data);
|
||||
}
|
||||
|
||||
_handleDisconnect() {
|
||||
this._cleanup();
|
||||
this._rejectPendingCalls("Connection lost");
|
||||
if (this.connection && !this.connection.resolved) {
|
||||
this.connection.resolved = true;
|
||||
this.connection.reject(new Error("Connection failed"));
|
||||
}
|
||||
if (this.shouldReconnect && !this._isDestroyed) {
|
||||
this._reconnectAttempts++;
|
||||
if (this._reconnectAttempts <= this._maxReconnectAttempts) {
|
||||
const delay = Math.min(
|
||||
this._reconnectDelay * Math.pow(1.5, Math.min(this._reconnectAttempts - 1, 10)),
|
||||
30000
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (!this._isDestroyed && this.shouldReconnect) {
|
||||
console.log(`Reconnecting (attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts})`);
|
||||
this.emit("reconnecting");
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
console.error("Max reconnection attempts reached");
|
||||
this.emit("reconnect_failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_rejectPendingCalls(reason) {
|
||||
try {
|
||||
for (const [callId, timeoutId] of this._pendingCalls) {
|
||||
try {
|
||||
clearTimeout(timeoutId);
|
||||
this.emit(callId, { data: null, error: reason, success: false });
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this._pendingCalls.clear();
|
||||
} catch (e) {
|
||||
console.error("Failed to reject pending calls:", e);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this.shouldReconnect = false;
|
||||
this._cleanup();
|
||||
this._rejectPendingCalls("Disconnected");
|
||||
}
|
||||
|
||||
if (this.shouldReconnect)
|
||||
setTimeout(() => {
|
||||
console.log("Reconnecting");
|
||||
this.emit("reconnecting");
|
||||
return this.connect();
|
||||
}, 4000);
|
||||
destroy() {
|
||||
this._isDestroyed = true;
|
||||
this.disconnect();
|
||||
this.removeAllEventListeners();
|
||||
}
|
||||
|
||||
_camelToSnake(str) {
|
||||
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
if (!str || typeof str !== "string") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
} catch (e) {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
get client() {
|
||||
@ -113,39 +273,128 @@ export class Socket extends EventHandler {
|
||||
{},
|
||||
{
|
||||
get(_, prop) {
|
||||
if (!prop || typeof prop !== "string") {
|
||||
return () => Promise.reject(new Error("Invalid method name"));
|
||||
}
|
||||
return (...args) => {
|
||||
const functionName = me._camelToSnake(prop);
|
||||
if(me._debug){
|
||||
const call = {}
|
||||
call[functionName] = args
|
||||
console.debug(call)
|
||||
try {
|
||||
const functionName = me._camelToSnake(prop);
|
||||
if (me._debug) {
|
||||
const call = {};
|
||||
call[functionName] = args;
|
||||
console.debug(call);
|
||||
}
|
||||
return me.call(functionName, ...args);
|
||||
} catch (e) {
|
||||
console.error("Client call error:", e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
generateCallId() {
|
||||
return self.crypto.randomUUID();
|
||||
try {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
} catch (e) {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||||
}
|
||||
}
|
||||
|
||||
async sendJson(data) {
|
||||
await this.connect().then((api) => {
|
||||
api.ws.send(JSON.stringify(data));
|
||||
});
|
||||
if (this._isDestroyed) {
|
||||
throw new Error("Socket destroyed");
|
||||
}
|
||||
if (!data) {
|
||||
throw new Error("No data to send");
|
||||
}
|
||||
try {
|
||||
await this.connect();
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("WebSocket not open");
|
||||
}
|
||||
const jsonStr = JSON.stringify(data);
|
||||
this.ws.send(jsonStr);
|
||||
} catch (e) {
|
||||
console.error("sendJson error:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async call(method, ...args) {
|
||||
const call = {
|
||||
callId: this.generateCallId(),
|
||||
if (this._isDestroyed) {
|
||||
return Promise.reject(new Error("Socket destroyed"));
|
||||
}
|
||||
if (!method || typeof method !== "string") {
|
||||
return Promise.reject(new Error("Invalid method name"));
|
||||
}
|
||||
const callId = this.generateCallId();
|
||||
const callData = {
|
||||
callId,
|
||||
method,
|
||||
args,
|
||||
args: args || [],
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
this.addEventListener(call.callId, (data) => resolve(data), { once: true});
|
||||
this.sendJson(call);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
this._pendingCalls.delete(callId);
|
||||
this.removeEventListener(callId, handler);
|
||||
reject(new Error(`RPC call timeout: ${method}`));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}, this._callTimeout);
|
||||
this._pendingCalls.set(callId, timeoutId);
|
||||
const handler = (response) => {
|
||||
try {
|
||||
clearTimeout(timeoutId);
|
||||
this._pendingCalls.delete(callId);
|
||||
if (response && !response.success && response.error) {
|
||||
reject(new Error(response.error));
|
||||
} else {
|
||||
resolve(response ? response.data : null);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
try {
|
||||
this.addEventListener(callId, handler, { once: true });
|
||||
this.sendJson(callData).catch((e) => {
|
||||
clearTimeout(timeoutId);
|
||||
this._pendingCalls.delete(callId);
|
||||
this.removeEventListener(callId, handler);
|
||||
reject(e);
|
||||
});
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId);
|
||||
this._pendingCalls.delete(callId);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callWithRetry(method, args = [], maxRetries = 3) {
|
||||
let lastError;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await this.call(method, ...args);
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
1311
src/snek/view/rpc.py
1311
src/snek/view/rpc.py
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user