create-caspian-app 1.8.2 → 1.8.4
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.
|
@@ -40,6 +40,16 @@ There is no status line inside an open connection, so failure is a frame:
|
|
|
40
40
|
`{"error": "..."}` -- that key alone -- followed by a close. The client
|
|
41
41
|
runtime routes it to `onError` rather than `onMessage`.
|
|
42
42
|
|
|
43
|
+
## Liveness
|
|
44
|
+
|
|
45
|
+
A quiet connection is not a dead one. The client runtime sends a heartbeat,
|
|
46
|
+
`{"__pp": "ping"}`, every 25 seconds and the server answers
|
|
47
|
+
`{"__pp": "pong"}`; neither frame ever reaches a handler or `onMessage`. The
|
|
48
|
+
idle timeout (`WEBSOCKET_IDLE_TIMEOUT_SECONDS`) therefore only fires for a
|
|
49
|
+
peer that stopped answering, and it closes with `IDLE_CLOSE_CODE` (4000)
|
|
50
|
+
rather than 1000, so the client knows to reconnect. A 1000 close still means
|
|
51
|
+
the handler returned: the conversation ended, and the client stays closed.
|
|
52
|
+
|
|
43
53
|
## Registration timing
|
|
44
54
|
|
|
45
55
|
A `@socket()` in a route's `index.py` registers when that module is first
|
|
@@ -95,8 +105,17 @@ ARGS_TIMEOUT_SECONDS = 10
|
|
|
95
105
|
SEND_QUEUE_SIZE = 32
|
|
96
106
|
|
|
97
107
|
|
|
108
|
+
# The client runtime pings every 25 seconds, and a background tab may stretch
|
|
109
|
+
# that to once a minute (Chrome's intensive timer throttling). The floor keeps
|
|
110
|
+
# a misconfigured timeout from closing healthy, quiet connections in a loop.
|
|
111
|
+
MIN_IDLE_TIMEOUT_SECONDS = 60
|
|
112
|
+
|
|
113
|
+
|
|
98
114
|
def _idle_timeout_seconds() -> int:
|
|
99
|
-
return max(
|
|
115
|
+
return max(
|
|
116
|
+
MIN_IDLE_TIMEOUT_SECONDS,
|
|
117
|
+
int(os.getenv("WEBSOCKET_IDLE_TIMEOUT_SECONDS", 120)),
|
|
118
|
+
)
|
|
100
119
|
|
|
101
120
|
|
|
102
121
|
def _max_message_bytes() -> int:
|
|
@@ -254,6 +273,26 @@ def socket(require_auth: bool = False, allowed_roles: list[str] | None = None):
|
|
|
254
273
|
|
|
255
274
|
# ==== WIRE HELPERS ====
|
|
256
275
|
|
|
276
|
+
# Close code for a connection the server dropped because nothing arrived in
|
|
277
|
+
# time. It is deliberately not 1000: a normal closure means the conversation
|
|
278
|
+
# ended (the handler returned), and the client does not reconnect after one.
|
|
279
|
+
# An idle close means the peer stopped answering -- the client runtime
|
|
280
|
+
# reconnects after it.
|
|
281
|
+
IDLE_CLOSE_CODE = 4000
|
|
282
|
+
|
|
283
|
+
# The control frame key. An object whose only key is `__pp` is wire
|
|
284
|
+
# housekeeping, never a message: the client runtime sends `{"__pp": "ping"}`
|
|
285
|
+
# as its heartbeat and the server answers `{"__pp": "pong"}`. Neither side
|
|
286
|
+
# hands a control frame to application code.
|
|
287
|
+
CONTROL_KEY = "__pp"
|
|
288
|
+
_PONG_FRAME = json.dumps({CONTROL_KEY: "pong"})
|
|
289
|
+
|
|
290
|
+
# How many received messages may wait for a handler that is not reading yet.
|
|
291
|
+
# Past this the connection is closed with an error frame rather than grown
|
|
292
|
+
# without bound; the per-connection message rate already caps how fast it can
|
|
293
|
+
# fill.
|
|
294
|
+
RECV_QUEUE_SIZE = 64
|
|
295
|
+
|
|
257
296
|
|
|
258
297
|
def _error_frame(message: str) -> str:
|
|
259
298
|
"""`{"error": "..."}` -- the frame shape the client routes to `onError`.
|
|
@@ -272,6 +311,28 @@ def _is_reserved_error_shape(value: Any) -> bool:
|
|
|
272
311
|
)
|
|
273
312
|
|
|
274
313
|
|
|
314
|
+
def _is_control_shape(value: Any) -> bool:
|
|
315
|
+
return isinstance(value, dict) and set(value.keys()) == {CONTROL_KEY}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _control_of(text: str) -> str | None:
|
|
319
|
+
"""The control verb of a heartbeat frame, or None for an ordinary one.
|
|
320
|
+
|
|
321
|
+
Cheap substring gate first: nearly every frame is application data, and
|
|
322
|
+
those never pay for a second JSON parse.
|
|
323
|
+
"""
|
|
324
|
+
if CONTROL_KEY not in text:
|
|
325
|
+
return None
|
|
326
|
+
try:
|
|
327
|
+
value = json.loads(text)
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
return None
|
|
330
|
+
if not _is_control_shape(value):
|
|
331
|
+
return None
|
|
332
|
+
verb = value[CONTROL_KEY]
|
|
333
|
+
return verb if isinstance(verb, str) else ""
|
|
334
|
+
|
|
335
|
+
|
|
275
336
|
class _MessageRate:
|
|
276
337
|
"""Sliding-window receive budget for one connection.
|
|
277
338
|
|
|
@@ -305,6 +366,11 @@ class _Shared:
|
|
|
305
366
|
self.outgoing: asyncio.Queue[str | None] = asyncio.Queue(maxsize=SEND_QUEUE_SIZE)
|
|
306
367
|
self.closed = False
|
|
307
368
|
self.last_activity = time.monotonic()
|
|
369
|
+
# The code the writer closes with once it drains. 1000 unless the
|
|
370
|
+
# server is ending the connection for a reason the client should act
|
|
371
|
+
# on (see IDLE_CLOSE_CODE).
|
|
372
|
+
self.close_code = status.WS_1000_NORMAL_CLOSURE
|
|
373
|
+
self.close_reason: str | None = None
|
|
308
374
|
|
|
309
375
|
|
|
310
376
|
class SocketSender:
|
|
@@ -334,6 +400,11 @@ class SocketSender:
|
|
|
334
400
|
'The frame shape {"error": "..."} is reserved for failures. '
|
|
335
401
|
"Wrap the value or rename the key."
|
|
336
402
|
)
|
|
403
|
+
if _is_control_shape(serialized):
|
|
404
|
+
raise ValueError(
|
|
405
|
+
'The frame shape {"__pp": ...} is reserved for the wire\'s '
|
|
406
|
+
"heartbeat. Wrap the value or rename the key."
|
|
407
|
+
)
|
|
337
408
|
try:
|
|
338
409
|
frame = json.dumps(serialized)
|
|
339
410
|
except (TypeError, ValueError) as e:
|
|
@@ -391,6 +462,13 @@ class Socket:
|
|
|
391
462
|
another task or a [`SocketPool`] may hold, which is how a chat room
|
|
392
463
|
reaches the people in it.
|
|
393
464
|
|
|
465
|
+
Two tasks own the wire. The writer is the only task that sends, so cloned
|
|
466
|
+
senders on other tasks never interleave partial sends. The reader is the
|
|
467
|
+
only task that receives, and it keeps reading whether or not the handler
|
|
468
|
+
is: that is what lets the heartbeat be answered, the idle timeout be
|
|
469
|
+
enforced, and a disconnect be noticed by a handler that only ever sends
|
|
470
|
+
(a notification feed awaiting `socket.wait_closed()`).
|
|
471
|
+
|
|
394
472
|
When the handler returns, the connection closes: a handler that returns
|
|
395
473
|
is a conversation that ends.
|
|
396
474
|
"""
|
|
@@ -401,9 +479,11 @@ class Socket:
|
|
|
401
479
|
self._shared = _Shared()
|
|
402
480
|
self._sender = SocketSender(self._shared)
|
|
403
481
|
self._rate = _MessageRate(_messages_per_window(), _rate_window_seconds())
|
|
404
|
-
|
|
405
|
-
|
|
482
|
+
self._incoming: asyncio.Queue[str | None] = asyncio.Queue(maxsize=RECV_QUEUE_SIZE)
|
|
483
|
+
self._incoming_done = False
|
|
484
|
+
self._peer_gone = asyncio.Event()
|
|
406
485
|
self._writer = asyncio.create_task(self._pump_outgoing())
|
|
486
|
+
self._reader = asyncio.create_task(self._pump_incoming())
|
|
407
487
|
|
|
408
488
|
async def _pump_outgoing(self) -> None:
|
|
409
489
|
try:
|
|
@@ -418,11 +498,85 @@ class Socket:
|
|
|
418
498
|
pass
|
|
419
499
|
finally:
|
|
420
500
|
self._shared.closed = True
|
|
501
|
+
self._peer_gone.set()
|
|
421
502
|
try:
|
|
422
|
-
await self._websocket.close(
|
|
503
|
+
await self._websocket.close(
|
|
504
|
+
code=self._shared.close_code, reason=self._shared.close_reason
|
|
505
|
+
)
|
|
423
506
|
except Exception:
|
|
424
507
|
pass
|
|
425
508
|
|
|
509
|
+
async def _pump_incoming(self) -> None:
|
|
510
|
+
"""Read every frame for as long as the connection lives.
|
|
511
|
+
|
|
512
|
+
Heartbeats are answered here and never reach the handler. Application
|
|
513
|
+
frames are checked against the size and rate limits and queued for
|
|
514
|
+
`recv`. The idle timeout counts traffic in both directions -- a
|
|
515
|
+
passive listener in an active room is not idle, it is listening --
|
|
516
|
+
and closes with IDLE_CLOSE_CODE so the client knows to reconnect.
|
|
517
|
+
"""
|
|
518
|
+
idle_timeout = _idle_timeout_seconds()
|
|
519
|
+
try:
|
|
520
|
+
while not self._shared.closed:
|
|
521
|
+
# Wait only for what is left of the idle window. Waiting a
|
|
522
|
+
# full window each time let a connection with any outbound
|
|
523
|
+
# traffic shortly after its last receive survive almost two
|
|
524
|
+
# windows before the check could fire.
|
|
525
|
+
remaining = idle_timeout - (time.monotonic() - self._shared.last_activity)
|
|
526
|
+
if remaining <= 0:
|
|
527
|
+
self._shared.close_code = IDLE_CLOSE_CODE
|
|
528
|
+
self._shared.close_reason = "idle timeout"
|
|
529
|
+
await self._shared.outgoing.put(None)
|
|
530
|
+
return
|
|
531
|
+
try:
|
|
532
|
+
text = await asyncio.wait_for(self._websocket.receive_text(), timeout=remaining)
|
|
533
|
+
except asyncio.TimeoutError:
|
|
534
|
+
continue
|
|
535
|
+
except Exception:
|
|
536
|
+
# Disconnect, or receive after close: the peer is gone.
|
|
537
|
+
# The write side finds out on its next send.
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
self._shared.last_activity = time.monotonic()
|
|
541
|
+
|
|
542
|
+
if len(text.encode("utf-8")) > _max_message_bytes():
|
|
543
|
+
await self._close_with(status.WS_1009_MESSAGE_TOO_BIG)
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
verb = _control_of(text)
|
|
547
|
+
if verb is not None:
|
|
548
|
+
# Unknown verbs are ignored rather than refused, so a
|
|
549
|
+
# newer client never breaks an older server.
|
|
550
|
+
if verb == "ping" and not self._shared.closed:
|
|
551
|
+
await self._shared.outgoing.put(_PONG_FRAME)
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
if not self._rate.allow():
|
|
555
|
+
await self._sender._error("Too many messages. Slow down.")
|
|
556
|
+
return
|
|
557
|
+
try:
|
|
558
|
+
self._incoming.put_nowait(text)
|
|
559
|
+
except asyncio.QueueFull:
|
|
560
|
+
await self._sender._error(
|
|
561
|
+
f"`{self._name}` is not reading its messages. The "
|
|
562
|
+
"handler must call socket.recv() to receive them."
|
|
563
|
+
)
|
|
564
|
+
return
|
|
565
|
+
finally:
|
|
566
|
+
self._peer_gone.set()
|
|
567
|
+
self._end_incoming()
|
|
568
|
+
|
|
569
|
+
def _end_incoming(self) -> None:
|
|
570
|
+
"""Queue the end-of-conversation marker `recv` answers None for."""
|
|
571
|
+
while True:
|
|
572
|
+
try:
|
|
573
|
+
self._incoming.put_nowait(None)
|
|
574
|
+
return
|
|
575
|
+
except asyncio.QueueFull:
|
|
576
|
+
# A full queue of unread frames the handler will never get to:
|
|
577
|
+
# the conversation is over, so the end marker takes priority.
|
|
578
|
+
self._incoming.get_nowait()
|
|
579
|
+
|
|
426
580
|
async def send(self, value: Any) -> bool:
|
|
427
581
|
"""Send one JSON value. False means the browser is gone."""
|
|
428
582
|
return await self._sender.send(value)
|
|
@@ -450,36 +604,31 @@ class Socket:
|
|
|
450
604
|
"""The next frame as it arrived, for a handler that would rather
|
|
451
605
|
parse it itself. None is the connection closing.
|
|
452
606
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
607
|
+
The shared limits are enforced by the reader before a frame gets
|
|
608
|
+
here: an oversized frame closes with 1009, a flooding connection gets
|
|
609
|
+
an error frame, and a connection idle in both directions past the
|
|
610
|
+
timeout closes with IDLE_CLOSE_CODE. Heartbeat frames never arrive.
|
|
456
611
|
"""
|
|
457
|
-
|
|
458
|
-
|
|
612
|
+
if self._incoming_done:
|
|
613
|
+
return None
|
|
614
|
+
text = await self._incoming.get()
|
|
615
|
+
if text is None:
|
|
616
|
+
self._incoming_done = True
|
|
617
|
+
return text
|
|
618
|
+
|
|
619
|
+
async def wait_closed(self) -> None:
|
|
620
|
+
"""Wait until the browser is gone or the connection closed.
|
|
621
|
+
|
|
622
|
+
For a handler that only sends -- a notification feed that parks its
|
|
623
|
+
sender in a pool -- and so has no `recv` loop to end on:
|
|
624
|
+
|
|
625
|
+
pool.add(socket.sender())
|
|
459
626
|
try:
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
await self.close()
|
|
466
|
-
return None
|
|
467
|
-
continue
|
|
468
|
-
except Exception:
|
|
469
|
-
# Disconnect, or receive after close: the conversation ended.
|
|
470
|
-
self._shared.closed = True
|
|
471
|
-
return None
|
|
472
|
-
|
|
473
|
-
self._shared.last_activity = time.monotonic()
|
|
474
|
-
|
|
475
|
-
if len(text.encode("utf-8")) > _max_message_bytes():
|
|
476
|
-
await self._close_with(status.WS_1009_MESSAGE_TOO_BIG)
|
|
477
|
-
return None
|
|
478
|
-
if not self._rate.allow():
|
|
479
|
-
await self._sender._error("Too many messages. Slow down.")
|
|
480
|
-
return None
|
|
481
|
-
return text
|
|
482
|
-
return None
|
|
627
|
+
await socket.wait_closed()
|
|
628
|
+
finally:
|
|
629
|
+
pool.discard(socket.sender())
|
|
630
|
+
"""
|
|
631
|
+
await self._peer_gone.wait()
|
|
483
632
|
|
|
484
633
|
def sender(self) -> SocketSender:
|
|
485
634
|
"""A sending handle another task, or a [`SocketPool`], may hold."""
|
|
@@ -498,6 +647,7 @@ class Socket:
|
|
|
498
647
|
|
|
499
648
|
async def _close_with(self, code: int) -> None:
|
|
500
649
|
self._shared.closed = True
|
|
650
|
+
self._peer_gone.set()
|
|
501
651
|
self._writer.cancel()
|
|
502
652
|
try:
|
|
503
653
|
await self._websocket.close(code=code)
|
|
@@ -505,12 +655,18 @@ class Socket:
|
|
|
505
655
|
pass
|
|
506
656
|
|
|
507
657
|
async def _finish(self) -> None:
|
|
508
|
-
"""Let queued frames drain, then reclaim
|
|
658
|
+
"""Let queued frames drain, then reclaim both pump tasks."""
|
|
509
659
|
try:
|
|
510
660
|
await asyncio.wait_for(self._writer, timeout=5)
|
|
511
661
|
except asyncio.CancelledError, Exception:
|
|
512
662
|
self._writer.cancel()
|
|
513
663
|
self._shared.closed = True
|
|
664
|
+
if self._reader is not asyncio.current_task() and not self._reader.done():
|
|
665
|
+
self._reader.cancel()
|
|
666
|
+
try:
|
|
667
|
+
await self._reader
|
|
668
|
+
except asyncio.CancelledError, Exception:
|
|
669
|
+
pass
|
|
514
670
|
|
|
515
671
|
|
|
516
672
|
# ==== ENDPOINT ====
|