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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshsocket
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol.
5
5
  Author: Carter Beaudoin
6
6
  License-Expression: MIT
@@ -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.1.0"
9
+ __all__ = ["MeshSocket", "LogColors", "sanitize_identity", "valid_ident", "IDENT_RE", "NAMESPACED_RE"]
10
+ __version__ = "0.2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshsocket
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol.
5
5
  Author: Carter Beaudoin
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshsocket"
7
- version = "0.1.1"
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
- self.id = str(uuid.uuid4())
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
- self.on("handshake", self._handle_handshake)
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
- self.on("status_request", self._handle_status_request)
132
- self.on("welcome", self._handle_welcome)
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.url}"
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
- asyncio.create_task(self._maintain_connection())
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.url}...{LogColors.ENDC}")
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) as ws:
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) -> Optional[str]:
326
- msg_id = str(uuid.uuid4())
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
- with open(self.offline_file_path, "a") as f:
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
- if line.strip():
384
- await self.connection.send(line.strip())
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
- msg_id = await self.send(type, payload)
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
- asyncio.create_task(self._process_packet(data))
430
- except json.JSONDecodeError:
431
- pass
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
- if reply_to and reply_to in self._pending_requests:
439
- future = self._pending_requests.pop(reply_to)
440
- if not future.done(): future.set_result(payload)
441
- return
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
- server_id = payload.get("id")
452
- if server_id:
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
- # Admit the connection (client mode). None in server mode.
456
- if self._admitted_event is not None:
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'))