better-rtplot 0.4.7__tar.gz → 0.4.9__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.
Files changed (36) hide show
  1. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/PKG-INFO +1 -1
  2. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/pyproject.toml +1 -1
  3. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/client.py +80 -6
  4. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/server_browser.py +98 -42
  5. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/LICENSE +0 -0
  6. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/README.md +0 -0
  7. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/interactive_test.py +0 -0
  8. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/saved_plots/.gitignore +0 -0
  9. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/server.py +0 -0
  10. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/server_browser_gui.py +0 -0
  11. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/index.html +0 -0
  12. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/auto-render.min.js +0 -0
  13. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  15. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  16. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  17. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  18. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  19. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  20. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  21. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  22. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  23. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  24. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  25. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  26. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  27. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  28. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  29. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  30. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  33. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/katex.min.css +0 -0
  34. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/katex/katex.min.js +0 -0
  35. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/uPlot.iife.min.js +0 -0
  36. {better_rtplot-0.4.7 → better_rtplot-0.4.9}/rtplot/static/uPlot.min.css +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-rtplot
3
- Version: 0.4.7
3
+ Version: 0.4.9
4
4
  Summary:
5
5
  License: GPL V3.0
6
6
  Author: jmontp
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.4.7"
3
+ version = "0.4.9"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -414,8 +414,17 @@ def send_array(A, flags=0, copy=True, track=False):
414
414
  socket.send(A, flags, copy=copy, track=track)
415
415
 
416
416
 
417
- def initialize_plots(plot_descriptions=1):
418
- """Send a json description of desired plot.
417
+ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
418
+ """Send a json description of desired plot and block until the
419
+ server acknowledges it.
420
+
421
+ Without the handshake, a fast script that calls ``initialize_plots()``
422
+ and immediately starts firing ``send_array()`` can lose the config
423
+ frame to ZMQ's PUB/SUB slow-joiner window — the server then drops
424
+ every subsequent data frame because no plot is declared yet and the
425
+ browser just stays blank. We resend the config on a short schedule
426
+ until the server PUSHes back a ``config_ack``, guaranteeing the
427
+ plot is live by the time this call returns.
419
428
 
420
429
  Inputs
421
430
  ------
@@ -426,6 +435,11 @@ def initialize_plots(plot_descriptions=1):
426
435
  - list of str: one plot, one trace per name
427
436
  - list of list of str: one plot per sublist
428
437
  - list of dict: multiple plots, each with full styling
438
+ handshake_timeout:
439
+ Max seconds to wait for the config_ack. On timeout we print a
440
+ warning and return (so an old server without the handshake
441
+ feature, or a server on a flaky network, doesn't block the
442
+ caller forever). Set to 0 to skip the handshake entirely.
429
443
  """
430
444
  global plot_desc_dict
431
445
 
@@ -477,11 +491,65 @@ def initialize_plots(plot_descriptions=1):
477
491
  else:
478
492
  raise TypeError("Incorrect usage of initialize_plots, verify github for usage")
479
493
 
480
- #Send the category
481
- socket.send_string(SENDING_PLOT_UPDATE)
494
+ _send_initialize_with_handshake(plot_desc_dict, handshake_timeout)
482
495
 
483
- #Send the description
484
- socket.send_json(plot_desc_dict)
496
+
497
+ def _send_initialize_with_handshake(cfg, timeout):
498
+ """Publish ``cfg`` and block until the server acks it.
499
+
500
+ Any control events (button presses, slider updates) that happen to
501
+ arrive during the handshake are stashed in the module-level caches
502
+ so the next ``poll_controls()`` call still sees them — we don't
503
+ want the handshake to swallow real user interaction.
504
+ """
505
+ global _control_button_events
506
+
507
+ def _send_once():
508
+ socket.send_string(SENDING_PLOT_UPDATE)
509
+ socket.send_json(cfg)
510
+
511
+ _send_once()
512
+
513
+ if timeout is None or timeout <= 0:
514
+ return # opt-out: fire-and-forget like the pre-handshake behavior
515
+
516
+ deadline = time.time() + timeout
517
+ resend_interval = 0.2
518
+ last_send = time.time()
519
+
520
+ poller = zmq.Poller()
521
+ poller.register(control_socket, zmq.POLLIN)
522
+
523
+ while time.time() < deadline:
524
+ remaining_ms = max(10, int((deadline - time.time()) * 1000))
525
+ next_resend_ms = max(1, int((last_send + resend_interval - time.time()) * 1000))
526
+ wait_ms = min(remaining_ms, next_resend_ms)
527
+ events = dict(poller.poll(wait_ms))
528
+ if control_socket in events:
529
+ try:
530
+ event = control_socket.recv_json(flags=zmq.NOBLOCK)
531
+ except (zmq.Again, zmq.ZMQError):
532
+ continue
533
+ evtype = event.get("type")
534
+ if evtype == "config_ack":
535
+ return
536
+ elif evtype == "button":
537
+ _control_button_events.append(event.get("id"))
538
+ elif evtype == "slider":
539
+ _control_slider_values[event.get("id")] = float(event.get("value", 0.0))
540
+ # Unknown / resend_config: ignore during handshake.
541
+ # Resend if the window elapsed without an ack; PUB/SUB will
542
+ # drop the first send during slow-join but retries land.
543
+ if time.time() - last_send >= resend_interval:
544
+ _send_once()
545
+ last_send = time.time()
546
+
547
+ print(
548
+ "rtplot.client: initialize_plots() timed out waiting for server ack"
549
+ f" after {timeout:.1f}s — the plot config may have been dropped."
550
+ " If you're on an older server (rtplot < 0.4.9) this warning is"
551
+ " expected and harmless."
552
+ )
485
553
 
486
554
 
487
555
  def set_display(display_id: str, value):
@@ -538,6 +606,12 @@ def poll_controls():
538
606
  if plot_desc_dict is not None:
539
607
  socket.send_string(SENDING_PLOT_UPDATE)
540
608
  socket.send_json(plot_desc_dict)
609
+ # Surface a one-line confirmation so the user can see in
610
+ # their script log that the recovery handshake worked.
611
+ # If they never see this after clicking Reconnect, the
612
+ # PUSH/PULL channel back from the server isn't wired —
613
+ # check that poll_controls() is actually being called.
614
+ print("rtplot.client: resent cached initialize_plots() config")
541
615
 
542
616
  buttons = _control_button_events
543
617
  _control_button_events = []
@@ -215,7 +215,13 @@ RECEIVED_DISPLAY = 4
215
215
  BIND_ME_ID = "bind_me"
216
216
  BIND_ME_NAME = "Shared - Bind to me"
217
217
 
218
- TABS_FILE = os.path.join(os.path.expanduser("~"), ".rtplot", "tabs.json")
218
+ # Path to the persisted tabs JSON. Honors RTPLOT_TABS_FILE so tests
219
+ # (and operators with multi-user setups) can override without symlinking
220
+ # their HOME directory or losing access to ~/.local site-packages.
221
+ TABS_FILE = os.environ.get(
222
+ "RTPLOT_TABS_FILE",
223
+ os.path.join(os.path.expanduser("~"), ".rtplot", "tabs.json"),
224
+ )
219
225
 
220
226
  zmq_ctx = zmq.asyncio.Context()
221
227
 
@@ -602,43 +608,67 @@ def _remove_ws(ws):
602
608
  def _open_tab_sockets(tab: Tab):
603
609
  """Open ``tab``'s data + control sockets according to its mode.
604
610
 
605
- On failure, sets tab.status="error" and tab.error=<reason>; the tab
606
- stays in the list but won't stream until re-opened.
611
+ For bind tabs, the just-closed sockets may leave the OS-level port
612
+ in a transient state for a few hundred ms (especially on Windows /
613
+ WSL). We retry the bind with a small backoff so the user-visible
614
+ Reconnect button doesn't fail spuriously the very first time. On
615
+ sustained failure we set tab.status="error" + tab.error=<reason>;
616
+ the tab stays in the list but won't stream until re-opened.
607
617
  """
608
618
  # Close any prior sockets first (e.g. on retry).
609
619
  _close_tab_sockets(tab)
610
620
 
611
- try:
612
- data = zmq_ctx.socket(zmq.SUB)
613
- data.setsockopt_string(zmq.SUBSCRIBE, "")
614
- ctrl = zmq_ctx.socket(zmq.PUSH)
615
- ctrl.setsockopt(zmq.SNDHWM, 1000)
616
- ctrl.setsockopt(zmq.LINGER, 0)
617
-
618
- if tab.mode == "bind":
619
- data.bind(f"tcp://*:{ZMQ_DEFAULT_PORT}")
620
- ctrl.bind(f"tcp://*:{ZMQ_CONTROL_PORT}")
621
- tab.endpoint = f"*:{ZMQ_DEFAULT_PORT}"
622
- print(f"[{tab.id}] ZMQ: bound on tcp://*:{ZMQ_DEFAULT_PORT}")
623
- else:
624
- data_ep, label = _normalize_connect_target(tab.endpoint)
625
- data.connect(data_ep)
626
- ctrl_ep, _ = _normalize_connect_target(
627
- _control_target_from_data(tab.endpoint), port=ZMQ_CONTROL_PORT
628
- )
629
- ctrl.connect(ctrl_ep)
630
- tab.endpoint = label
631
- print(f"[{tab.id}] ZMQ: connected to {data_ep} (ctrl {ctrl_ep})")
621
+ last_exc: Exception | None = None
622
+ attempts = 4 if tab.mode == "bind" else 1
623
+ for attempt in range(attempts):
624
+ try:
625
+ data = zmq_ctx.socket(zmq.SUB)
626
+ data.setsockopt_string(zmq.SUBSCRIBE, "")
627
+ data.setsockopt(zmq.LINGER, 0)
628
+ ctrl = zmq_ctx.socket(zmq.PUSH)
629
+ ctrl.setsockopt(zmq.SNDHWM, 1000)
630
+ ctrl.setsockopt(zmq.LINGER, 0)
631
+
632
+ if tab.mode == "bind":
633
+ data.bind(f"tcp://*:{ZMQ_DEFAULT_PORT}")
634
+ ctrl.bind(f"tcp://*:{ZMQ_CONTROL_PORT}")
635
+ tab.endpoint = f"*:{ZMQ_DEFAULT_PORT}"
636
+ print(f"[{tab.id}] ZMQ: bound on tcp://*:{ZMQ_DEFAULT_PORT}")
637
+ else:
638
+ data_ep, label = _normalize_connect_target(tab.endpoint)
639
+ data.connect(data_ep)
640
+ ctrl_ep, _ = _normalize_connect_target(
641
+ _control_target_from_data(tab.endpoint), port=ZMQ_CONTROL_PORT
642
+ )
643
+ ctrl.connect(ctrl_ep)
644
+ tab.endpoint = label
645
+ print(f"[{tab.id}] ZMQ: connected to {data_ep} (ctrl {ctrl_ep})")
646
+
647
+ tab.data_sock = data
648
+ tab.ctrl_sock = ctrl
649
+ tab.status = "idle"
650
+ tab.error = None
651
+ return
652
+ except Exception as exc: # noqa: BLE001
653
+ last_exc = exc
654
+ # Drop the half-built sockets before retrying so we don't
655
+ # leak descriptors and so the next attempt re-takes the port.
656
+ try: data.close(0)
657
+ except Exception: pass
658
+ try: ctrl.close(0)
659
+ except Exception: pass
660
+ if attempt < attempts - 1:
661
+ wait = 0.15 * (attempt + 1)
662
+ print(
663
+ f"[{tab.id}] ZMQ open attempt {attempt + 1}/{attempts}"
664
+ f" failed ({exc}); retrying in {wait:.2f}s"
665
+ )
666
+ time.sleep(wait)
632
667
 
633
- tab.data_sock = data
634
- tab.ctrl_sock = ctrl
635
- tab.status = "idle"
636
- tab.error = None
637
- except Exception as exc: # noqa: BLE001
638
- tab.status = "error"
639
- tab.error = str(exc)
640
- print(f"[{tab.id}] ZMQ open failed: {exc}")
641
- _close_tab_sockets(tab)
668
+ tab.status = "error"
669
+ tab.error = str(last_exc) if last_exc is not None else "unknown error"
670
+ print(f"[{tab.id}] ZMQ open failed after {attempts} attempts: {last_exc}")
671
+ _close_tab_sockets(tab)
642
672
 
643
673
 
644
674
  def _close_tab_sockets(tab: Tab):
@@ -750,6 +780,12 @@ async def zmq_receiver(tab: Tab):
750
780
  last_time = now
751
781
  tab.status = "streaming"
752
782
  tab.error = None
783
+ # Blocking-handshake ack: tells initialize_plots() on the
784
+ # client side that the config made it past PUB/SUB's
785
+ # slow-joiner window so it can stop resending and return.
786
+ # Old clients ignore unknown event types, so this is safe
787
+ # to emit unconditionally.
788
+ await send_control_event(tab, {"type": "config_ack"})
753
789
  await broadcast_text_tab(tab.id, tab.config_message)
754
790
  await broadcast_tab(tab.id)
755
791
  snap = make_snapshot_message(tab)
@@ -1001,24 +1037,44 @@ async def _request_config_resend(tab: "Tab"):
1001
1037
  the cached config so the plot picks back up without the user having
1002
1038
  to restart their script.
1003
1039
 
1004
- Retries on a brief schedule because the client's PULL socket may need
1005
- a moment to auto-reconnect to the freshly bound PUSH socket — a
1006
- one-shot DONTWAIT send to a peer-less PUSH gets silently dropped.
1007
- Stops early once we see ``tab.initialized`` flip to True (the client
1008
- has answered) so we don't spam.
1040
+ Retries over a ~10 s window because:
1041
+ * the client's PULL socket may need a moment to auto-reconnect to
1042
+ the freshly bound PUSH socket especially over a WAN/SSH link;
1043
+ * a slow user loop may only call poll_controls() once per second,
1044
+ so the message can sit unread in the PULL queue for a while;
1045
+ * the client may not be running at all (no peer), in which case
1046
+ the queued message is just dropped when the next reconnect happens.
1047
+ Stops early once tab.initialized flips True (the client answered).
1009
1048
  """
1010
- for i, delay in enumerate((0.4, 1.0, 2.0)):
1049
+ schedule = (0.3, 0.7, 1.5, 2.5, 5.0) # cumulative ~10 s
1050
+ started_initialized = tab.initialized
1051
+ for i, delay in enumerate(schedule):
1011
1052
  await asyncio.sleep(delay)
1012
1053
  if tab.ctrl_sock is None:
1013
1054
  return
1014
- if i > 0 and tab.initialized:
1055
+ if i > 0 and tab.initialized and not started_initialized:
1015
1056
  return
1016
1057
  try:
1017
1058
  await tab.ctrl_sock.send_json(
1018
1059
  {"type": "resend_config"}, flags=zmq.DONTWAIT
1019
1060
  )
1020
- except (zmq.Again, zmq.ZMQError):
1021
- pass
1061
+ if DEBUG_TEXT_ENABLED:
1062
+ print(f"[{tab.id}] resend_config sent (attempt {i + 1}/{len(schedule)})")
1063
+ except (zmq.Again, zmq.ZMQError) as exc:
1064
+ if DEBUG_TEXT_ENABLED:
1065
+ print(
1066
+ f"[{tab.id}] resend_config attempt {i + 1}/{len(schedule)}"
1067
+ f" failed: {exc} (likely no peer connected yet)"
1068
+ )
1069
+ # If we asked the client to re-init but nothing came back, surface
1070
+ # a hint so the user knows where to look. Common causes: the client
1071
+ # is on rtplot < 0.4.6 (no resend_config handler), or its loop never
1072
+ # calls poll_controls(), or there's no client running at all.
1073
+ if not started_initialized and not tab.initialized:
1074
+ print(
1075
+ f"[{tab.id}] no config received after resend_config nudges."
1076
+ " Is the client running rtplot >= 0.4.6 and calling poll_controls()?"
1077
+ )
1022
1078
 
1023
1079
 
1024
1080
  async def reconnect_tab(tab_id: str):
File without changes
File without changes