create-caspian-app 1.8.1 → 1.8.3

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.
@@ -26,8 +26,6 @@ def layout():
26
26
  href="/favicon.ico"
27
27
  type="image/x-icon"
28
28
  sizes="16x16" />
29
- <link href="/css/styles.css" rel="stylesheet" />
30
- <script type="module" src="/js/main.js"></script>
31
29
  </head>
32
30
 
33
31
  <body style="
@@ -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(10, int(os.getenv("WEBSOCKET_IDLE_TIMEOUT_SECONDS", 120)))
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
- # One task owns the write side of the wire, so cloned senders on other
405
- # tasks never interleave partial sends.
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(code=status.WS_1000_NORMAL_CLOSURE)
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
- Enforces the shared limits: an oversized frame closes with 1009, a
454
- flooding connection closes with 1008, and a connection idle in both
455
- directions past the timeout closes with 1000.
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
- idle_timeout = _idle_timeout_seconds()
458
- while not self._shared.closed:
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
- text = await asyncio.wait_for(self._websocket.receive_text(), timeout=idle_timeout)
461
- except asyncio.TimeoutError:
462
- # Outbound traffic counts as liveness: a passive listener in
463
- # an active room is not idle, it is listening.
464
- if time.monotonic() - self._shared.last_activity >= idle_timeout:
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 the writer task."""
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 ====
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",