meshsocket 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {meshsocket-0.1.1 → meshsocket-0.2.0}/PKG-INFO +1 -1
- {meshsocket-0.1.1 → meshsocket-0.2.0}/meshsocket/__init__.py +3 -3
- {meshsocket-0.1.1 → meshsocket-0.2.0}/meshsocket.egg-info/PKG-INFO +1 -1
- {meshsocket-0.1.1 → meshsocket-0.2.0}/pyproject.toml +1 -1
- {meshsocket-0.1.1 → meshsocket-0.2.0}/socketCore.py +238 -29
- meshsocket-0.2.0/socket_server.py +627 -0
- meshsocket-0.1.1/socket_server.py +0 -370
- {meshsocket-0.1.1 → meshsocket-0.2.0}/LICENSE +0 -0
- {meshsocket-0.1.1 → meshsocket-0.2.0}/README.md +0 -0
- {meshsocket-0.1.1 → meshsocket-0.2.0}/meshsocket.egg-info/SOURCES.txt +0 -0
- {meshsocket-0.1.1 → meshsocket-0.2.0}/meshsocket.egg-info/dependency_links.txt +0 -0
- {meshsocket-0.1.1 → meshsocket-0.2.0}/meshsocket.egg-info/requires.txt +0 -0
- {meshsocket-0.1.1 → meshsocket-0.2.0}/meshsocket.egg-info/top_level.txt +0 -0
- {meshsocket-0.1.1 → meshsocket-0.2.0}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
`from socketCore import MeshSocket` continues to work (the module is published alongside)
|
|
5
5
|
so existing code keeps running while it migrates to the package name.
|
|
6
6
|
"""
|
|
7
|
-
from socketCore import MeshSocket, LogColors
|
|
7
|
+
from socketCore import MeshSocket, LogColors, sanitize_identity, valid_ident, IDENT_RE, NAMESPACED_RE
|
|
8
8
|
|
|
9
|
-
__all__ = ["MeshSocket", "LogColors"]
|
|
10
|
-
__version__ = "0.
|
|
9
|
+
__all__ = ["MeshSocket", "LogColors", "sanitize_identity", "valid_ident", "IDENT_RE", "NAMESPACED_RE"]
|
|
10
|
+
__version__ = "0.2.0"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meshsocket"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -3,14 +3,56 @@ import certifi
|
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
5
|
import ssl
|
|
6
|
+
import stat
|
|
6
7
|
import uuid
|
|
7
8
|
import time
|
|
8
9
|
import logging
|
|
9
10
|
import os
|
|
11
|
+
import re
|
|
10
12
|
import websockets
|
|
13
|
+
from urllib.parse import urlsplit
|
|
11
14
|
from collections import deque
|
|
12
15
|
from typing import Callable, Dict, Any, List, Optional
|
|
13
16
|
|
|
17
|
+
# Identity grammar enforced by the relay (see socket_server): a plain identifier,
|
|
18
|
+
# or the carter-relay gateway's `<account digits>.<identifier>` form.
|
|
19
|
+
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
|
20
|
+
NAMESPACED_RE = re.compile(r"^[0-9]{1,32}\.[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
|
21
|
+
_NS_PREFIX_RE = re.compile(r"^([0-9]{1,32})\.(.*)$", re.S)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def valid_ident(value: Any) -> bool:
|
|
25
|
+
return isinstance(value, str) and bool(IDENT_RE.match(value) or NAMESPACED_RE.match(value))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sanitize_identity(value: Any, kind: str = "name") -> str:
|
|
29
|
+
"""Turn a free-form string into a name/channel the relay accepts.
|
|
30
|
+
|
|
31
|
+
The relay itself does NOT sanitize: an identify whose name or channel fails
|
|
32
|
+
the grammar is closed with 1008. Clients call this before identifying (for
|
|
33
|
+
example on a device name such as "Carter's iPhone" -> "Carter-s-iPhone").
|
|
34
|
+
|
|
35
|
+
Rule (identical in relay, gateway and app): `value` must be a str of 1..256
|
|
36
|
+
chars, else ValueError. A leading `<digits>.` namespace prefix is kept and
|
|
37
|
+
only the remainder is sanitized. Every char outside [A-Za-z0-9_-] becomes
|
|
38
|
+
"-", runs of "-" collapse to one, leading/trailing "-" (and a leading "_",
|
|
39
|
+
which the grammar forbids) are stripped, the result is truncated to 64 chars,
|
|
40
|
+
and an empty result becomes "node" (kind="name") or "default"
|
|
41
|
+
(kind="channel"). The output always satisfies `valid_ident`.
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 256:
|
|
44
|
+
raise ValueError(f"{kind} must be a string of 1..256 characters")
|
|
45
|
+
prefix = ""
|
|
46
|
+
m = _NS_PREFIX_RE.match(value)
|
|
47
|
+
if m:
|
|
48
|
+
prefix, value = m.group(1) + ".", m.group(2)
|
|
49
|
+
out = re.sub(r"[^A-Za-z0-9_-]", "-", value)
|
|
50
|
+
out = re.sub(r"-{2,}", "-", out).strip("-").lstrip("_")
|
|
51
|
+
out = out[:64].rstrip("-")
|
|
52
|
+
if not out:
|
|
53
|
+
out = "node" if kind == "name" else "default"
|
|
54
|
+
return prefix + out
|
|
55
|
+
|
|
14
56
|
# ANSI Colors for nicer logs
|
|
15
57
|
class LogColors:
|
|
16
58
|
HEADER = '\033[95m'
|
|
@@ -75,9 +117,22 @@ class MeshSocket:
|
|
|
75
117
|
can_route: bool | None = None,
|
|
76
118
|
can_cross_channel_route: bool | None = None,
|
|
77
119
|
can_monitor: bool | None = None,
|
|
78
|
-
broadcast_scope: str | None = None
|
|
120
|
+
broadcast_scope: str | None = None,
|
|
121
|
+
ping_interval: float | None = 30.0,
|
|
122
|
+
ping_timeout: float | None = 10.0,
|
|
123
|
+
close_timeout: float | None = 5.0):
|
|
79
124
|
|
|
80
125
|
self.url = url
|
|
126
|
+
# Client keepalive: a WebSocket ping every `ping_interval` seconds; if
|
|
127
|
+
# the pong takes longer than `ping_timeout` the socket is closed (1011)
|
|
128
|
+
# and the supervisor reconnects. None disables (library default was an
|
|
129
|
+
# implicit 20/20). The relay pings independently.
|
|
130
|
+
self.ping_interval = ping_interval
|
|
131
|
+
self.ping_timeout = ping_timeout
|
|
132
|
+
# How long to wait for the peer's close frame once WE decide to close
|
|
133
|
+
# (e.g. after a pong timeout). A dead peer never answers, so this bounds
|
|
134
|
+
# how long a detected-dead socket lingers before the supervisor redials.
|
|
135
|
+
self.close_timeout = close_timeout
|
|
81
136
|
self.connection = connection
|
|
82
137
|
self.auth_token = auth_token or os.getenv('MESH_AUTH_TOKEN')
|
|
83
138
|
self.channel = channel or os.getenv("MESH_CHANNEL")
|
|
@@ -93,7 +148,10 @@ class MeshSocket:
|
|
|
93
148
|
else:
|
|
94
149
|
self.name = name
|
|
95
150
|
|
|
96
|
-
|
|
151
|
+
# `id` is a property: the server freezes it once the client is registered
|
|
152
|
+
# (see `freeze_id`) so no later frame can move a node in the routing table.
|
|
153
|
+
self._id = str(uuid.uuid4())
|
|
154
|
+
self._id_frozen = False
|
|
97
155
|
|
|
98
156
|
# Callbacks
|
|
99
157
|
self.on_reconnect_callback = on_reconnect
|
|
@@ -110,6 +168,10 @@ class MeshSocket:
|
|
|
110
168
|
|
|
111
169
|
# State
|
|
112
170
|
self.is_running = False
|
|
171
|
+
# Set by stop(reason=...) when the client is being torn down because of a
|
|
172
|
+
# FAULT rather than an ordinary shutdown; surfaced when the supervisor exits.
|
|
173
|
+
self.stopped_reason: Optional[str] = None
|
|
174
|
+
self._maintain_task: Optional[asyncio.Task] = None
|
|
113
175
|
|
|
114
176
|
# Lazy-initialised inside the event loop to avoid DeprecationWarning
|
|
115
177
|
# in Python 3.10+ when constructing outside an async context.
|
|
@@ -126,10 +188,34 @@ class MeshSocket:
|
|
|
126
188
|
self.handlers: Dict[str, Callable] = {}
|
|
127
189
|
self._pending_requests: Dict[str, asyncio.Future] = {}
|
|
128
190
|
|
|
129
|
-
|
|
191
|
+
# Optional pre-dispatch gate: `authorize(msg_type) -> bool`. The server
|
|
192
|
+
# installs one per connection so unidentified sockets can reach nothing
|
|
193
|
+
# but `identify`; None means every registered handler is reachable.
|
|
194
|
+
self.authorize: Optional[Callable[[str], bool]] = None
|
|
195
|
+
|
|
196
|
+
# `ping` is answered in both modes. The rest are CLIENT-mode handlers:
|
|
197
|
+
# a server-side node must never let its peer rewrite its id via
|
|
198
|
+
# `welcome`, nor answer `handshake`/`status_request` before the server's
|
|
199
|
+
# own auth gate has admitted the socket.
|
|
130
200
|
self.on("ping", self._handle_ping)
|
|
131
|
-
|
|
132
|
-
|
|
201
|
+
if connection is None:
|
|
202
|
+
self.on("handshake", self._handle_handshake)
|
|
203
|
+
self.on("status_request", self._handle_status_request)
|
|
204
|
+
self.on("welcome", self._handle_welcome)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def id(self) -> str:
|
|
208
|
+
return self._id
|
|
209
|
+
|
|
210
|
+
@id.setter
|
|
211
|
+
def id(self, value: str):
|
|
212
|
+
if self._id_frozen:
|
|
213
|
+
raise AttributeError("MeshSocket.id is immutable once registered")
|
|
214
|
+
self._id = value
|
|
215
|
+
|
|
216
|
+
def freeze_id(self):
|
|
217
|
+
"""Make `id` read-only. The server calls this right after registration."""
|
|
218
|
+
self._id_frozen = True
|
|
133
219
|
|
|
134
220
|
@property
|
|
135
221
|
def connected_event(self) -> asyncio.Event:
|
|
@@ -141,7 +227,25 @@ class MeshSocket:
|
|
|
141
227
|
return self._connected_event
|
|
142
228
|
|
|
143
229
|
def __str__(self):
|
|
144
|
-
return f"{self.name}, {self.
|
|
230
|
+
return f"{self.name}, {self.safe_url}"
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def safe_url(self) -> Optional[str]:
|
|
234
|
+
"""scheme://host[:port] only — never the path or query, which may carry a
|
|
235
|
+
token, and which therefore must never reach a log line."""
|
|
236
|
+
if not self.url:
|
|
237
|
+
return self.url
|
|
238
|
+
try:
|
|
239
|
+
parts = urlsplit(self.url)
|
|
240
|
+
except ValueError:
|
|
241
|
+
return "<unparseable url>"
|
|
242
|
+
if not parts.scheme or not parts.hostname:
|
|
243
|
+
return "<unparseable url>"
|
|
244
|
+
host = parts.hostname
|
|
245
|
+
if ":" in host:
|
|
246
|
+
host = f"[{host}]"
|
|
247
|
+
port = f":{parts.port}" if parts.port else ""
|
|
248
|
+
return f"{parts.scheme}://{host}{port}"
|
|
145
249
|
|
|
146
250
|
def on(self, type: str, func: Callable = None):
|
|
147
251
|
if func is None:
|
|
@@ -202,12 +306,25 @@ class MeshSocket:
|
|
|
202
306
|
raise ValueError("Cannot use start() without a URL.")
|
|
203
307
|
self._start_time = time.time()
|
|
204
308
|
self.is_running = True
|
|
205
|
-
|
|
309
|
+
self.stopped_reason = None
|
|
310
|
+
# Hold the reference: the loop keeps only a weak one, so an unreferenced
|
|
311
|
+
# task can be garbage-collected mid-flight and take the reconnect
|
|
312
|
+
# supervisor down silently.
|
|
313
|
+
self._maintain_task = asyncio.create_task(self._maintain_connection())
|
|
206
314
|
|
|
207
315
|
async def wait_until_ready(self):
|
|
208
316
|
await self.connected_event.wait()
|
|
209
317
|
|
|
210
|
-
async def stop(self):
|
|
318
|
+
async def stop(self, reason: str = None):
|
|
319
|
+
"""Shut the client down for good (the supervisor will NOT reconnect).
|
|
320
|
+
|
|
321
|
+
`reason` marks this as a FAULT rather than an intentional shutdown: it
|
|
322
|
+
is recorded on `stopped_reason` and logged at ERROR when the supervisor
|
|
323
|
+
exits. Callers giving up on a socket should always pass one — a caller
|
|
324
|
+
that reuses the plain shutdown path to mean "fatal error" produces a
|
|
325
|
+
dead client that never reconnects and never says why.
|
|
326
|
+
"""
|
|
327
|
+
self.stopped_reason = reason
|
|
211
328
|
self.is_running = False
|
|
212
329
|
if self.connection:
|
|
213
330
|
await self.connection.close()
|
|
@@ -227,20 +344,47 @@ class MeshSocket:
|
|
|
227
344
|
|
|
228
345
|
# --- INTERNAL CORE ---
|
|
229
346
|
async def _maintain_connection(self):
|
|
347
|
+
try:
|
|
348
|
+
await self._maintain_connection_inner()
|
|
349
|
+
finally:
|
|
350
|
+
# INVARIANT: this supervisor never dies quietly. While it is alive the
|
|
351
|
+
# client reconnects forever; once it exits the client is dead until
|
|
352
|
+
# something calls start() again, so every exit — intentional shutdown,
|
|
353
|
+
# fault, cancellation, or a bug — must leave a line in the log. A
|
|
354
|
+
# silent exit here once cost ~30 h of a "connected" client that was
|
|
355
|
+
# never coming back.
|
|
356
|
+
if self.stopped_reason:
|
|
357
|
+
logging.error(f"{LogColors.FAIL}{self.name} connection supervisor "
|
|
358
|
+
f"STOPPED: {self.stopped_reason} — this client will "
|
|
359
|
+
f"not reconnect until start() is called again"
|
|
360
|
+
f"{LogColors.ENDC}")
|
|
361
|
+
elif self.is_running:
|
|
362
|
+
logging.error(f"{LogColors.FAIL}{self.name} connection supervisor "
|
|
363
|
+
f"exited unexpectedly while still running — this "
|
|
364
|
+
f"client will NOT reconnect{LogColors.ENDC}")
|
|
365
|
+
else:
|
|
366
|
+
logging.info(f"{self.name} connection supervisor stopped (shutdown)")
|
|
367
|
+
|
|
368
|
+
async def _maintain_connection_inner(self):
|
|
230
369
|
retry_delay = 2
|
|
231
370
|
while self.is_running:
|
|
232
371
|
listen_task = None
|
|
233
372
|
admitted = False
|
|
234
373
|
try:
|
|
235
|
-
logging.info(f"{LogColors.BLUE}{self.name} connecting to {self.
|
|
374
|
+
logging.info(f"{LogColors.BLUE}{self.name} connecting to {self.safe_url}...{LogColors.ENDC}")
|
|
236
375
|
ssl_context = ssl.create_default_context(cafile=certifi.where()) if self.url.startswith("wss://") else None
|
|
237
|
-
async with websockets.connect(self.url, ssl=ssl_context
|
|
376
|
+
async with websockets.connect(self.url, ssl=ssl_context,
|
|
377
|
+
ping_interval=self.ping_interval,
|
|
378
|
+
ping_timeout=self.ping_timeout,
|
|
379
|
+
close_timeout=self.close_timeout) as ws:
|
|
238
380
|
self.connection = ws
|
|
239
381
|
retry_delay = 2
|
|
240
382
|
|
|
241
383
|
# Arm the admission gate before identify so a welcome delivered
|
|
242
|
-
# by the concurrently-running listen loop can't be missed.
|
|
384
|
+
# by the concurrently-running listen loop can't be missed. Each
|
|
385
|
+
# attempt gets a fresh server-assigned id, so unfreeze it here.
|
|
243
386
|
self._admitted_event = asyncio.Event()
|
|
387
|
+
self._id_frozen = False
|
|
244
388
|
|
|
245
389
|
# Send identify directly on the socket: connected_event is not
|
|
246
390
|
# set yet, so the normal send() path would buffer or raise.
|
|
@@ -322,8 +466,9 @@ class MeshSocket:
|
|
|
322
466
|
return False
|
|
323
467
|
|
|
324
468
|
# --- SEND LOGIC ---
|
|
325
|
-
async def send(self, type: str, payload: Any = None, reply_to: str = None
|
|
326
|
-
|
|
469
|
+
async def send(self, type: str, payload: Any = None, reply_to: str = None,
|
|
470
|
+
msg_id: str = None) -> Optional[str]:
|
|
471
|
+
msg_id = msg_id or str(uuid.uuid4())
|
|
327
472
|
packet = {"id": msg_id, "type": type, "payload": payload, "reply_to": reply_to}
|
|
328
473
|
packet_str = json.dumps(packet)
|
|
329
474
|
|
|
@@ -370,18 +515,46 @@ class MeshSocket:
|
|
|
370
515
|
await loop.run_in_executor(None, self._write_lines_to_file, [packet_str])
|
|
371
516
|
|
|
372
517
|
def _write_lines_to_file(self, lines: List[str]):
|
|
373
|
-
|
|
518
|
+
# Owner-only from the first byte: the buffer holds frames that are
|
|
519
|
+
# replayed verbatim onto an authenticated socket.
|
|
520
|
+
fd = os.open(self.offline_file_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
521
|
+
with os.fdopen(fd, "a") as f:
|
|
374
522
|
for line in lines:
|
|
375
523
|
f.write(line + "\n")
|
|
524
|
+
try:
|
|
525
|
+
os.chmod(self.offline_file_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
526
|
+
except OSError:
|
|
527
|
+
pass
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def _valid_buffered_frame(line: str) -> bool:
|
|
531
|
+
"""A replayable line is one JSON object with a string `type` (a frame this
|
|
532
|
+
client itself produced). Anything else was not written by us."""
|
|
533
|
+
try:
|
|
534
|
+
frame = json.loads(line)
|
|
535
|
+
except (ValueError, TypeError):
|
|
536
|
+
return False
|
|
537
|
+
return (isinstance(frame, dict) and isinstance(frame.get("type"), str)
|
|
538
|
+
and isinstance(frame.get("id"), str)
|
|
539
|
+
and frame.get("type") not in ("identify", "welcome"))
|
|
376
540
|
|
|
377
541
|
async def _flush_offline_queue(self):
|
|
378
542
|
if self.offline_file_path and os.path.exists(self.offline_file_path):
|
|
379
543
|
logging.info(f"{LogColors.WARNING}Flushing disk buffer...{LogColors.ENDC}")
|
|
380
544
|
try:
|
|
545
|
+
dropped = 0
|
|
381
546
|
with open(self.offline_file_path, "r") as f:
|
|
382
547
|
for line in f:
|
|
383
|
-
|
|
384
|
-
|
|
548
|
+
line = line.strip()
|
|
549
|
+
if not line:
|
|
550
|
+
continue
|
|
551
|
+
if not self._valid_buffered_frame(line):
|
|
552
|
+
dropped += 1
|
|
553
|
+
continue
|
|
554
|
+
await self.connection.send(line)
|
|
555
|
+
if dropped:
|
|
556
|
+
logging.warning(f"{LogColors.WARNING}Dropped {dropped} invalid line(s) from the "
|
|
557
|
+
f"disk buffer (not frames this client wrote){LogColors.ENDC}")
|
|
385
558
|
os.remove(self.offline_file_path)
|
|
386
559
|
except Exception as e:
|
|
387
560
|
logging.error(f"Failed to flush disk buffer: {e}")
|
|
@@ -397,16 +570,24 @@ class MeshSocket:
|
|
|
397
570
|
logging.warning(f"Request '{type}' waiting for connection...")
|
|
398
571
|
await self.connected_event.wait()
|
|
399
572
|
|
|
400
|
-
|
|
573
|
+
# Register the pending id BEFORE the frame leaves, so a reply that
|
|
574
|
+
# arrives faster than we can get back here is matched, not dropped.
|
|
575
|
+
msg_id = str(uuid.uuid4())
|
|
401
576
|
loop = asyncio.get_running_loop()
|
|
402
577
|
future = loop.create_future()
|
|
403
578
|
self._pending_requests[msg_id] = future
|
|
579
|
+
try:
|
|
580
|
+
await self.send(type, payload, msg_id=msg_id)
|
|
581
|
+
except BaseException:
|
|
582
|
+
self._pending_requests.pop(msg_id, None)
|
|
583
|
+
raise
|
|
404
584
|
try:
|
|
405
585
|
return await asyncio.wait_for(future, timeout=timeout)
|
|
406
586
|
except asyncio.TimeoutError:
|
|
407
587
|
self._pending_requests.pop(msg_id, None)
|
|
408
588
|
return None
|
|
409
589
|
except asyncio.CancelledError:
|
|
590
|
+
self._pending_requests.pop(msg_id, None)
|
|
410
591
|
return None
|
|
411
592
|
|
|
412
593
|
def _fail_all_pending_requests(self):
|
|
@@ -414,7 +595,20 @@ class MeshSocket:
|
|
|
414
595
|
if not future.done(): future.cancel()
|
|
415
596
|
self._pending_requests.clear()
|
|
416
597
|
|
|
598
|
+
# Inbound handler tasks in flight per connection. When all slots are busy
|
|
599
|
+
# the read loop waits, which lets the websocket's own buffer apply
|
|
600
|
+
# backpressure to the peer instead of spawning unbounded tasks.
|
|
601
|
+
MAX_INFLIGHT = 64
|
|
602
|
+
|
|
417
603
|
async def _listen_loop(self):
|
|
604
|
+
inflight = asyncio.Semaphore(self.MAX_INFLIGHT)
|
|
605
|
+
|
|
606
|
+
async def run(packet):
|
|
607
|
+
try:
|
|
608
|
+
await self._process_packet(packet)
|
|
609
|
+
finally:
|
|
610
|
+
inflight.release()
|
|
611
|
+
|
|
418
612
|
async for message in self.connection:
|
|
419
613
|
if self.rate_limit > 0:
|
|
420
614
|
now = time.time()
|
|
@@ -426,19 +620,31 @@ class MeshSocket:
|
|
|
426
620
|
|
|
427
621
|
try:
|
|
428
622
|
data = json.loads(message)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
623
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
624
|
+
continue
|
|
625
|
+
await inflight.acquire()
|
|
626
|
+
asyncio.create_task(run(data))
|
|
432
627
|
|
|
433
628
|
async def _process_packet(self, packet: dict):
|
|
629
|
+
if not isinstance(packet, dict):
|
|
630
|
+
return
|
|
434
631
|
msg_id = packet.get('id')
|
|
435
632
|
msg_type = packet.get('type')
|
|
436
633
|
payload = packet.get('payload')
|
|
437
634
|
reply_to = packet.get('reply_to')
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
635
|
+
# Single pre-dispatch gate (server installs it per connection): nothing
|
|
636
|
+
# — not replies, not `ping`, not `node_status` — is processed for a
|
|
637
|
+
# socket the gate has not admitted.
|
|
638
|
+
if self.authorize is not None and not self.authorize(msg_type):
|
|
639
|
+
return
|
|
640
|
+
if reply_to:
|
|
641
|
+
# A reply is only ever a reply. One that matches no pending request
|
|
642
|
+
# (late, duplicate, or forged) is dropped — never re-dispatched as
|
|
643
|
+
# a fresh request of its `type`.
|
|
644
|
+
future = self._pending_requests.pop(reply_to, None)
|
|
645
|
+
if future is not None and not future.done():
|
|
646
|
+
future.set_result(payload)
|
|
647
|
+
return
|
|
442
648
|
if msg_type in self.handlers:
|
|
443
649
|
try:
|
|
444
650
|
response = await self.handlers[msg_type](payload)
|
|
@@ -448,13 +654,16 @@ class MeshSocket:
|
|
|
448
654
|
logging.error(f"Error processing {msg_type}: {e}")
|
|
449
655
|
|
|
450
656
|
async def _handle_welcome(self, payload):
|
|
451
|
-
|
|
452
|
-
|
|
657
|
+
# Client mode only: a server-side node never accepts a peer's `welcome`
|
|
658
|
+
# (the handler is not registered there, and this guard is belt-and-braces).
|
|
659
|
+
if self._admitted_event is None:
|
|
660
|
+
return
|
|
661
|
+
server_id = payload.get("id") if isinstance(payload, dict) else None
|
|
662
|
+
if isinstance(server_id, str) and server_id and not self._id_frozen:
|
|
453
663
|
logging.info(f"{self.name} assigned server ID: {server_id}")
|
|
454
664
|
self.id = server_id
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
self._admitted_event.set()
|
|
665
|
+
self.freeze_id()
|
|
666
|
+
self._admitted_event.set()
|
|
458
667
|
|
|
459
668
|
async def _handle_handshake(self, payload):
|
|
460
669
|
t_remote = float(payload.get('t'))
|