arrayview 0.45.0__tar.gz → 0.45.1__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 (114) hide show
  1. {arrayview-0.45.0 → arrayview-0.45.1}/PKG-INFO +9 -1
  2. {arrayview-0.45.0 → arrayview-0.45.1}/README.md +8 -0
  3. {arrayview-0.45.0 → arrayview-0.45.1}/pyproject.toml +1 -1
  4. arrayview-0.45.1/src/arrayview/_bootstrap_app.py +120 -0
  5. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_launch_plan.py +6 -0
  6. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_launcher.py +48 -6
  7. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_platform.py +6 -0
  8. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_server.py +142 -106
  9. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_session.py +121 -1
  10. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_viewer.html +397 -148
  11. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_api.py +89 -9
  12. arrayview-0.45.1/tests/test_bootstrap_app.py +114 -0
  13. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_browser.py +294 -2
  14. arrayview-0.45.1/tests/test_roi_hud.py +183 -0
  15. {arrayview-0.45.0 → arrayview-0.45.1}/tests/visual_smoke.py +18 -6
  16. {arrayview-0.45.0 → arrayview-0.45.1}/.gitignore +0 -0
  17. {arrayview-0.45.0 → arrayview-0.45.1}/LICENSE +0 -0
  18. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/ARCHITECTURE.md +0 -0
  19. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/__init__.py +0 -0
  20. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/__main__.py +0 -0
  21. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_analysis.py +0 -0
  22. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_app.py +0 -0
  23. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_config.py +0 -0
  24. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_diagnostics.py +0 -0
  25. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_dicom.py +0 -0
  26. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_diff.py +0 -0
  27. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_extra_ports.py +0 -0
  28. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_icon.png +0 -0
  29. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_imaging.py +0 -0
  30. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_instance_registry.py +0 -0
  31. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_io.py +0 -0
  32. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_launch_trace.py +0 -0
  33. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_lifecycle.py +0 -0
  34. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_overlays.py +0 -0
  35. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_render.py +0 -0
  36. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_analysis.py +0 -0
  37. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_drop.py +0 -0
  38. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_export.py +0 -0
  39. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_loading.py +0 -0
  40. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_persistence.py +0 -0
  41. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_preferences.py +0 -0
  42. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_preload.py +0 -0
  43. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_query.py +0 -0
  44. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_rendering.py +0 -0
  45. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_segmentation.py +0 -0
  46. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_state.py +0 -0
  47. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_vectorfield.py +0 -0
  48. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_websocket.py +0 -0
  49. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_segmentation.py +0 -0
  50. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_session_spec.py +0 -0
  51. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_shell.html +0 -0
  52. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_source_safety.py +0 -0
  53. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_synthetic_mri.py +0 -0
  54. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_torch.py +0 -0
  55. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_tutorial.py +0 -0
  56. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vectorfield.py +0 -0
  57. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vscode.py +0 -0
  58. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vscode_browser.py +0 -0
  59. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vscode_extension.py +0 -0
  60. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vscode_signal.py +0 -0
  61. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_watch.py +0 -0
  62. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/arrayview-opener.vsix +0 -0
  63. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/gsap.min.js +0 -0
  64. {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/todo.md +0 -0
  65. {arrayview-0.45.0 → arrayview-0.45.1}/tests/capture_patient_loading.py +0 -0
  66. {arrayview-0.45.0 → arrayview-0.45.1}/tests/capture_v_animation.py +0 -0
  67. {arrayview-0.45.0 → arrayview-0.45.1}/tests/conftest.py +0 -0
  68. {arrayview-0.45.0 → arrayview-0.45.1}/tests/lifecycle_matrix.py +0 -0
  69. {arrayview-0.45.0 → arrayview-0.45.1}/tests/make_vectorfield_test_arrays.py +0 -0
  70. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_1d_arrays.py +0 -0
  71. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_backend_shared.py +0 -0
  72. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_cli.py +0 -0
  73. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_colorbar_hover_highlight.py +0 -0
  74. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_config.py +0 -0
  75. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_dicom.py +0 -0
  76. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_display_opener_results.py +0 -0
  77. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_doctor_commands.py +0 -0
  78. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_drop_import.py +0 -0
  79. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_instance_registry.py +0 -0
  80. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_invocation_contract_matrix.py +0 -0
  81. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_io.py +0 -0
  82. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_large_arrays.py +0 -0
  83. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_blackbox.py +0 -0
  84. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_diagnostics.py +0 -0
  85. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_execution_ownership.py +0 -0
  86. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_plan.py +0 -0
  87. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_trace.py +0 -0
  88. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_lifecycle_contract.py +0 -0
  89. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_loading_server.py +0 -0
  90. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_mode_consistency.py +0 -0
  91. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_mode_matrix.py +0 -0
  92. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_mode_roundtrip.py +0 -0
  93. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_nifti_meta.py +0 -0
  94. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_nifti_series.py +0 -0
  95. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_preferences_ui.py +0 -0
  96. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_public_invocation_regressions.py +0 -0
  97. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_render_failure_reporting.py +0 -0
  98. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_render_progress.py +0 -0
  99. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_rgb_pixel_art.py +0 -0
  100. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_safe_server_stop.py +0 -0
  101. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_server_identity_status.py +0 -0
  102. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_session_spec.py +0 -0
  103. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_source_safety.py +0 -0
  104. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_spawn_ownership.py +0 -0
  105. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_torch.py +0 -0
  106. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_tutorial.py +0 -0
  107. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_tutorial_browser.py +0 -0
  108. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_value_formatting.py +0 -0
  109. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_view_handle_lifecycle.py +0 -0
  110. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_view_launch_execution.py +0 -0
  111. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_vscode_ack_protocol.py +0 -0
  112. {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_window_level_preview.py +0 -0
  113. {arrayview-0.45.0 → arrayview-0.45.1}/tests/ui_audit.py +0 -0
  114. {arrayview-0.45.0 → arrayview-0.45.1}/tests/v_anim_frames/.gitkeep +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.45.0
3
+ Version: 0.45.1
4
4
  Summary: Fast multi-dimensional array viewer
5
5
  Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
6
  Project-URL: Source, https://github.com/oscarvanderheide/arrayview
@@ -59,6 +59,14 @@ uvx arrayview path/to/dicom-series/
59
59
  Press `v` for the three-plane ortho view. `Shift+3` replaces the current slice,
60
60
  or all three ortho panes, with interactive 3D cutaway renders.
61
61
  Drop another file onto an open viewer to compare, open separately, or overlay it.
62
+ Press `Shift+R` for ROI analysis: a compact HUD lists each region's mean and standard
63
+ deviation. The HUD sits beside the image when space allows; drag its header
64
+ to move it. Hover a row or region to highlight its counterpart, and use each
65
+ row's trash icon to delete that region. Open the detailed analysis with the
66
+ button beside the HUD's column headings.
67
+ Hold a flood-fill seed and drag up/down to adjust sensitivity; a translucent tapered gauge
68
+ at the starting point shows the current value and the 1%–100% range. The
69
+ pointer hides while adjusting; only the gauge marker moves.
62
70
 
63
71
 
64
72
  Check the [docs](https://oscarvanderheide.github.io/arrayview/) to learn more.
@@ -22,6 +22,14 @@ uvx arrayview path/to/dicom-series/
22
22
  Press `v` for the three-plane ortho view. `Shift+3` replaces the current slice,
23
23
  or all three ortho panes, with interactive 3D cutaway renders.
24
24
  Drop another file onto an open viewer to compare, open separately, or overlay it.
25
+ Press `Shift+R` for ROI analysis: a compact HUD lists each region's mean and standard
26
+ deviation. The HUD sits beside the image when space allows; drag its header
27
+ to move it. Hover a row or region to highlight its counterpart, and use each
28
+ row's trash icon to delete that region. Open the detailed analysis with the
29
+ button beside the HUD's column headings.
30
+ Hold a flood-fill seed and drag up/down to adjust sensitivity; a translucent tapered gauge
31
+ at the starting point shows the current value and the 1%–100% range. The
32
+ pointer hides while adjusting; only the gauge marker moves.
25
33
 
26
34
 
27
35
  Check the [docs](https://oscarvanderheide.github.io/arrayview/) to learn more.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.45.0"
7
+ version = "0.45.1"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,120 @@
1
+ """Answer the health check while the real server is still loading.
2
+
3
+ A freshly spawned daemon binds its port within ~150 ms, but importing the web
4
+ framework and the route modules takes another ~300-500 ms (more on a busy
5
+ host). Nothing that arrives in that window can be served by the real app, and
6
+ the launcher, the VS Code opener and the tunnel proxy all wait on ``/ping``
7
+ before they will open a viewer tab.
8
+
9
+ This tiny ASGI app is what uvicorn serves from the moment the socket is bound:
10
+
11
+ * ``GET /ping`` is answered immediately with the daemon's real identity (the
12
+ same payload the real route returns, built from ``_session``), so the tab
13
+ can be opened while the framework is still importing;
14
+ * every other request simply waits in line and is handed to the real app the
15
+ moment it is attached, so the page download overlaps the import instead of
16
+ starting after it;
17
+ * if the real app fails to load, waiting requests get a 503 and the daemon
18
+ exits, which is what happened before too — just later.
19
+
20
+ No ordering that the launch relies on changes: ``/ping`` still reports the
21
+ identity that the registry record carries, and the viewer page still cannot
22
+ arrive before the server that serves it exists.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import threading
30
+ from typing import Awaitable, Callable
31
+
32
+ Scope = dict
33
+ Receive = Callable[[], Awaitable[dict]]
34
+ Send = Callable[[dict], Awaitable[None]]
35
+
36
+
37
+ class BootstrapApp:
38
+ """ASGI app that answers ``/ping`` itself until the real app is attached."""
39
+
40
+ def __init__(self) -> None:
41
+ self._app = None
42
+ self._error: BaseException | None = None
43
+ self._ready = threading.Event()
44
+
45
+ # ── wiring (called from whichever thread loads the real app) ──────────
46
+ def attach(self, app) -> None:
47
+ self._app = app
48
+ self._ready.set()
49
+
50
+ def fail(self, error: BaseException) -> None:
51
+ self._error = error
52
+ self._ready.set()
53
+
54
+ @property
55
+ def ready(self) -> bool:
56
+ return self._app is not None
57
+
58
+ # ── ASGI ──────────────────────────────────────────────────────────────
59
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
60
+ kind = scope.get("type")
61
+ if kind == "lifespan":
62
+ await self._lifespan(receive, send)
63
+ return
64
+ if (
65
+ self._app is None
66
+ and kind == "http"
67
+ and scope.get("method") == "GET"
68
+ and scope.get("path") == "/ping"
69
+ ):
70
+ await self._ping(send)
71
+ return
72
+ if not self._ready.is_set():
73
+ loop = asyncio.get_running_loop()
74
+ await loop.run_in_executor(None, self._ready.wait)
75
+ if self._app is None:
76
+ await self._unavailable(kind, send)
77
+ return
78
+ await self._app(scope, receive, send)
79
+
80
+ async def _lifespan(self, receive: Receive, send: Send) -> None:
81
+ # The real app registers no startup/shutdown hooks, so uvicorn's
82
+ # lifespan protocol is answered here and never forwarded.
83
+ while True:
84
+ message = await receive()
85
+ if message["type"] == "lifespan.startup":
86
+ await send({"type": "lifespan.startup.complete"})
87
+ elif message["type"] == "lifespan.shutdown":
88
+ await send({"type": "lifespan.shutdown.complete"})
89
+ return
90
+
91
+ async def _ping(self, send: Send) -> None:
92
+ import arrayview._session as _session_mod
93
+
94
+ payload = _session_mod.ping_payload(
95
+ active_sessions=len(_session_mod.SESSIONS)
96
+ )
97
+ await _send_json(send, 200, payload)
98
+
99
+ async def _unavailable(self, kind: str | None, send: Send) -> None:
100
+ detail = f"ArrayView server failed to start: {self._error!r}"
101
+ if kind == "websocket":
102
+ await send({"type": "websocket.close", "code": 1011, "reason": detail[:120]})
103
+ return
104
+ await _send_json(send, 503, {"error": detail, "type": "ServerStartupFailed"})
105
+
106
+
107
+ async def _send_json(send: Send, status: int, payload: dict) -> None:
108
+ body = json.dumps(payload).encode("utf-8")
109
+ await send(
110
+ {
111
+ "type": "http.response.start",
112
+ "status": status,
113
+ "headers": [
114
+ (b"content-type", b"application/json"),
115
+ (b"content-length", str(len(body)).encode("ascii")),
116
+ (b"cache-control", b"no-store"),
117
+ ],
118
+ }
119
+ )
120
+ await send({"type": "http.response.body", "body": body})
@@ -9,6 +9,7 @@ import os
9
9
  import socket
10
10
  import sys
11
11
  import time
12
+ import urllib.error
12
13
  import urllib.request
13
14
  import uuid
14
15
 
@@ -710,6 +711,11 @@ def _ping_arrayview_server(port: int) -> dict | None:
710
711
  payload = json.loads(resp.read().decode("utf-8"))
711
712
  if payload.get("ok") is True and payload.get("service") == "arrayview":
712
713
  return payload
714
+ except urllib.error.URLError as exc:
715
+ # Nobody is listening on the port: retrying cannot change that,
716
+ # and the two 50 ms pauses were paid on every fresh launch.
717
+ if isinstance(exc.reason, ConnectionRefusedError):
718
+ return None
713
719
  except Exception:
714
720
  pass
715
721
  if attempt < 2:
@@ -155,15 +155,17 @@ def _register_server_runtime(port: int, owner_mode: str):
155
155
  os.chmod(log_directory, 0o700)
156
156
  except OSError:
157
157
  pass
158
+ # Identity lives in _session, not _server, so registering does not pull in
159
+ # the web framework: the daemon can answer /ping before that import runs.
158
160
  record = InstanceRecord.create(
159
161
  port=port,
160
- protocol_version=_server_mod().SERVER_PROTOCOL_VERSION,
162
+ protocol_version=_session_mod.SERVER_PROTOCOL_VERSION,
161
163
  package_version=__version__,
162
164
  owner_mode=owner_mode,
163
165
  log_path=str(log_directory / f"server-{os.getpid()}.log"),
164
166
  )
165
167
  registry.write(record)
166
- _server_mod().configure_server_runtime(
168
+ _session_mod.configure_server_runtime(
167
169
  instance_id=record.instance_id,
168
170
  process_start=record.process_start,
169
171
  owner_mode=record.owner_mode,
@@ -4622,13 +4624,24 @@ def _serve_daemon(
4622
4624
  port=record.port,
4623
4625
  )
4624
4626
 
4627
+ # Serve from the bootstrap app right away: /ping answers with this
4628
+ # daemon's identity while the web framework is still importing, so the
4629
+ # launcher and the opener can open the tab ~0.4 s sooner and the page
4630
+ # download overlaps the import. Every other request waits in line and is
4631
+ # served by the real app the moment it is attached.
4632
+ from arrayview._bootstrap_app import BootstrapApp
4633
+
4634
+ bootstrap = BootstrapApp()
4635
+ uvicorn_server: list = []
4636
+
4625
4637
  def _run_uvicorn_on_socket():
4626
4638
  config = _uvicorn().Config(
4627
- _server_mod().app,
4639
+ bootstrap,
4628
4640
  log_level="error",
4629
4641
  timeout_keep_alive=30,
4630
4642
  )
4631
4643
  server = _uvicorn().Server(config)
4644
+ uvicorn_server.append(server)
4632
4645
  asyncio.run(server.serve(sockets=sock))
4633
4646
 
4634
4647
  # Start uvicorn immediately — the window can open before data is ready.
@@ -4638,14 +4651,43 @@ def _serve_daemon(
4638
4651
  ).start()
4639
4652
  _trace_launch_event("backend.http_thread_started", port=port)
4640
4653
 
4641
- # Pre-warm colormap LUTs in background (saves ~200 ms on first frame render).
4642
- def _warm_luts():
4654
+ # Let the listener come up before any CPU-heavy import starts: with the
4655
+ # framework import, the colormap warm-up and the data load all competing
4656
+ # for the interpreter, uvicorn's own startup was measured taking ~0.6 s
4657
+ # instead of ~0.03 s, which delayed the first /ping answer by the same.
4658
+ serving_deadline = time.monotonic() + 5.0
4659
+ while time.monotonic() < serving_deadline:
4660
+ if uvicorn_server and getattr(uvicorn_server[0], "started", False):
4661
+ break
4662
+ time.sleep(0.002)
4663
+ _trace_launch_event("backend.serving", port=port)
4664
+
4665
+ def _attach_full_app():
4666
+ try:
4667
+ bootstrap.attach(_server_mod().app)
4668
+ except BaseException as exc: # noqa: BLE001 — any failure here is fatal
4669
+ bootstrap.fail(exc)
4670
+ _trace_launch_event("daemon.exiting", reason="server_import_failed")
4671
+ _vprint(f"[ArrayView] server failed to start: {exc!r}", flush=True)
4672
+ try:
4673
+ registry.remove(record.instance_id)
4674
+ except Exception:
4675
+ pass
4676
+ _cleanup_source_staging_dirs(source_staging_dirs)
4677
+ os._exit(1)
4678
+ _trace_launch_event("backend.app_attached", port=port)
4679
+ # Pre-warm colormap LUTs (the page and the first frame both need
4680
+ # them). Sequenced after the framework import rather than alongside
4681
+ # it: run together they contend for the interpreter and both finish
4682
+ # later than they do one after the other.
4643
4683
  try:
4644
4684
  from arrayview._render import _init_luts
4645
4685
  _init_luts()
4646
4686
  except Exception:
4647
4687
  pass
4648
- threading.Thread(target=_warm_luts, daemon=True).start()
4688
+ _trace_launch_event("backend.luts_ready", port=port)
4689
+
4690
+ threading.Thread(target=_attach_full_app, daemon=True).start()
4649
4691
 
4650
4692
  def _load():
4651
4693
  from arrayview._io import (
@@ -102,6 +102,12 @@ def _in_jupyter() -> bool:
102
102
  global _JUPYTER_CACHE
103
103
  if _JUPYTER_CACHE is not None:
104
104
  return _JUPYTER_CACHE
105
+ # A notebook kernel has IPython loaded before any user code runs, so a
106
+ # process without it cannot be one. Importing IPython just to ask costs
107
+ # ~250 ms on every terminal launch; skip it when it is not already there.
108
+ if "IPython" not in sys.modules:
109
+ _JUPYTER_CACHE = False
110
+ return False
105
111
  try:
106
112
  from IPython import get_ipython
107
113
 
@@ -9,7 +9,6 @@ import json
9
9
  import os
10
10
  import time
11
11
  import uuid
12
- from dataclasses import dataclass, replace
13
12
  from urllib.parse import urlencode
14
13
  from fastapi import FastAPI, HTTPException, Request
15
14
  from fastapi.responses import HTMLResponse, JSONResponse, Response
@@ -25,7 +24,6 @@ from arrayview._session import (
25
24
  COLORMAPS,
26
25
  )
27
26
  from arrayview import __version__ as _av_version
28
- from arrayview._instance_registry import process_start_identity
29
27
  import arrayview._session as _session_mod # for mutable VIEWER_SOCKETS
30
28
 
31
29
  from arrayview._render import (
@@ -68,75 +66,17 @@ from arrayview._imaging import ensure_image as _pil_image, ensure_imageops as _p
68
66
  app = FastAPI()
69
67
 
70
68
 
71
- SERVER_PROTOCOL_VERSION = "1"
72
- SERVER_CAPABILITIES = (
73
- "health-status",
74
- "session-registration",
75
- "identity-fenced-load",
76
- "identity-fenced-mutations",
77
- "transactional-relay-display",
78
- "viewer-websocket",
79
- "viewer-phase-journal",
80
- "shell-websocket",
81
- "dir-collection-case-inference",
82
- "staged-drop-import",
69
+ # Identity, capabilities and the /ping contract are owned by _session.py so a
70
+ # booting daemon can answer /ping before this module has imported. Re-exported
71
+ # here so callers and tests keep addressing them through the server module.
72
+ from arrayview._session import ( # noqa: E402
73
+ SERVER_CAPABILITIES,
74
+ SERVER_PROTOCOL_VERSION,
75
+ ServerRuntimeState,
76
+ configure_server_runtime,
83
77
  )
84
78
 
85
79
 
86
- def _environment_port() -> int | None:
87
- value = os.environ.get("ARRAYVIEW_SERVER_PORT")
88
- if not value:
89
- return None
90
- try:
91
- port = int(value)
92
- except ValueError:
93
- return None
94
- return port if 0 < port < 65536 else None
95
-
96
-
97
- def _environment_started_at() -> float:
98
- value = os.environ.get("ARRAYVIEW_STARTED_AT")
99
- if value:
100
- try:
101
- return float(value)
102
- except ValueError:
103
- pass
104
- return time.time()
105
-
106
-
107
- @dataclass(frozen=True)
108
- class ServerRuntimeState:
109
- """Stable identity and ownership metadata for this server process."""
110
-
111
- instance_id: str
112
- process_start: str
113
- owner_mode: str
114
- started_at: float
115
- port: int | None
116
- protocol_version: str = SERVER_PROTOCOL_VERSION
117
- capabilities: tuple[str, ...] = SERVER_CAPABILITIES
118
-
119
-
120
- if _session_mod.SERVER_RUNTIME is None:
121
- _session_mod.SERVER_RUNTIME = ServerRuntimeState(
122
- instance_id=os.environ.get("ARRAYVIEW_INSTANCE_ID") or str(uuid.uuid4()),
123
- process_start=(
124
- os.environ.get("ARRAYVIEW_PROCESS_START")
125
- or process_start_identity(os.getpid())
126
- or f"pid-only:{os.getpid()}"
127
- ),
128
- owner_mode=os.environ.get("ARRAYVIEW_OWNER_MODE", "unknown"),
129
- started_at=_environment_started_at(),
130
- port=_environment_port(),
131
- )
132
-
133
-
134
- def configure_server_runtime(**changes) -> ServerRuntimeState:
135
- """Set launch metadata once the listener's final ownership/port is known."""
136
- _session_mod.SERVER_RUNTIME = replace(_session_mod.SERVER_RUNTIME, **changes)
137
- return _session_mod.SERVER_RUNTIME
138
-
139
-
140
80
  @app.exception_handler(Exception)
141
81
  async def _generic_exception_handler(request: Request, exc: Exception):
142
82
  import traceback
@@ -164,6 +104,42 @@ _GSAP_JS: str = (
164
104
  _pkg_files("arrayview").joinpath("gsap.min.js").read_text(encoding="utf-8")
165
105
  )
166
106
 
107
+
108
+ def _split_viewer_template(template: str) -> tuple[str, str, str]:
109
+ """Split the viewer into a per-launch page and one static script.
110
+
111
+ Everything in the main script below ``__AV_STATIC_SCRIPT_BELOW__`` is
112
+ byte-identical on every launch (all substituted values sit above the
113
+ marker), so it is served as ``viewer-<hash>.js`` with an immutable cache
114
+ header instead of being re-sent inside every page. On a VS Code tunnel
115
+ that is ~400 KB gzipped per open that the browser now keeps. The source
116
+ file stays one self-contained HTML document; the split happens here.
117
+ Returns ``(page_template, static_js, static_hash)``; without the marker
118
+ the whole file is served inline exactly as before.
119
+ """
120
+ marker = "__AV_STATIC_SCRIPT_BELOW__"
121
+ marker_at = template.find(marker)
122
+ if marker_at < 0:
123
+ return template, "", ""
124
+ script_start = template.index("\n", marker_at) + 1
125
+ script_end = template.index("</script>", script_start)
126
+ static_js = template[script_start:script_end]
127
+ import hashlib
128
+
129
+ digest = hashlib.sha256(static_js.encode("utf-8")).hexdigest()[:16]
130
+ page = (
131
+ template[:script_start]
132
+ + f' </script>\n <script src="viewer-{digest}.js">'
133
+ + template[script_end:]
134
+ )
135
+ return page, static_js, digest
136
+
137
+
138
+ _VIEWER_PAGE_TEMPLATE, _VIEWER_STATIC_JS, _VIEWER_STATIC_JS_HASH = (
139
+ _split_viewer_template(_VIEWER_HTML_TEMPLATE)
140
+ )
141
+ _VIEWER_STATIC_JS_GZIP: bytes | None = None
142
+
167
143
  # The viewer page is ~2 MB of single-file HTML. On loopback that is free, but a
168
144
  # VS Code tunnel relays every byte through a cloud endpoint, where the same
169
145
  # payload has been observed taking 8-18 s. gzip cuts it to ~380 KB (5x), so the
@@ -207,11 +183,53 @@ def _text_response(
207
183
  return Response(content=packed, media_type=media_type, headers=out)
208
184
 
209
185
 
186
+ @app.get("/viewer-{digest}.js")
187
+ def serve_viewer_static_js(digest: str, request: Request):
188
+ """Serve the launch-independent part of the viewer script.
189
+
190
+ The digest is in the URL, so a changed viewer gets a new address and the
191
+ browser may keep this one forever; a stale address is simply unknown.
192
+ """
193
+ if not _VIEWER_STATIC_JS or digest != _VIEWER_STATIC_JS_HASH:
194
+ return Response(status_code=404)
195
+ headers = {"Cache-Control": "public, max-age=31536000, immutable"}
196
+ if not _accepts_gzip(request):
197
+ return Response(
198
+ content=_VIEWER_STATIC_JS,
199
+ media_type="application/javascript",
200
+ headers=headers,
201
+ )
202
+ global _VIEWER_STATIC_JS_GZIP
203
+ if _VIEWER_STATIC_JS_GZIP is None:
204
+ import gzip
205
+
206
+ _VIEWER_STATIC_JS_GZIP = gzip.compress(_VIEWER_STATIC_JS.encode("utf-8"), 6)
207
+ headers["Content-Encoding"] = "gzip"
208
+ headers["Vary"] = "Accept-Encoding"
209
+ return Response(
210
+ content=_VIEWER_STATIC_JS_GZIP,
211
+ media_type="application/javascript",
212
+ headers=headers,
213
+ )
214
+
215
+
216
+ _GSAP_ETAG = '"' + __import__("hashlib").sha256(_GSAP_JS.encode("utf-8")).hexdigest()[:16] + '"'
217
+
218
+
210
219
  @app.get("/gsap.min.js")
211
220
  def serve_gsap(request: Request):
212
- """Serve vendored GSAP library (browser caches via ETag)."""
221
+ """Serve the vendored GSAP library.
222
+
223
+ Cached for a day and revalidated by ETag after that, so a repeat launch
224
+ costs one small round trip instead of another 28 KB through the tunnel.
225
+ """
226
+ if request.headers.get("if-none-match") == _GSAP_ETAG:
227
+ return Response(status_code=304, headers={"ETag": _GSAP_ETAG})
213
228
  return _text_response(
214
- _GSAP_JS, request=request, media_type="application/javascript"
229
+ _GSAP_JS,
230
+ request=request,
231
+ media_type="application/javascript",
232
+ headers={"Cache-Control": "max-age=86400", "ETag": _GSAP_ETAG},
215
233
  )
216
234
 
217
235
 
@@ -347,40 +365,7 @@ async def cold_start_port(payload: dict):
347
365
  @app.get("/ping")
348
366
  def ping():
349
367
  """Health marker so clients can verify this is an ArrayView server."""
350
- import socket
351
-
352
- runtime = _session_mod.SERVER_RUNTIME
353
- return {
354
- "ok": True,
355
- "service": "arrayview",
356
- "pid": os.getpid(),
357
- "uid": os.geteuid() if hasattr(os, "geteuid") else None,
358
- "hostname": socket.gethostname(),
359
- "protocol_version": runtime.protocol_version,
360
- "package_version": _av_version,
361
- "instance_id": runtime.instance_id,
362
- "process_start": runtime.process_start,
363
- "owner_mode": runtime.owner_mode,
364
- "started_at": runtime.started_at,
365
- "port": runtime.port,
366
- "capabilities": list(runtime.capabilities),
367
- "active_sessions": len(SESSIONS),
368
- "active_viewer_sockets": _session_mod.VIEWER_SOCKETS,
369
- "active_shell_sockets": len(_session_mod.SHELL_SOCKETS),
370
- "viewer_sockets": _session_mod.VIEWER_SOCKETS,
371
- "viewer_connections_seen": _session_mod.VIEWER_CONNECTIONS_SEEN,
372
- "shell_sockets": len(_session_mod.SHELL_SOCKETS),
373
- "shell_request_ids": sorted(_session_mod.SHELL_REQUEST_IDS),
374
- "active_viewer_requests": sorted(
375
- f"{sid}:{request_id}"
376
- for (sid, request_id), count in _session_mod.VIEWER_REQUEST_COUNTS.items()
377
- if count > 0
378
- ),
379
- "native_ready_requests": sorted(
380
- f"{sid}:{request_id}"
381
- for sid, request_id in _session_mod.NATIVE_READY_REQUESTS
382
- ),
383
- }
368
+ return _session_mod.ping_payload(active_sessions=len(SESSIONS))
384
369
 
385
370
 
386
371
  @app.get("/status")
@@ -428,13 +413,50 @@ def _trace_page_request(
428
413
  pass
429
414
 
430
415
 
416
+ def _boot_metadata_json(sid: str | None) -> str:
417
+ """The array's description, for the page to carry instead of asking.
418
+
419
+ The viewer otherwise waits for the server to push this over the freshly
420
+ opened WebSocket, which costs half a round trip before it can request its
421
+ first frame — noticeable over a VS Code tunnel. The server already knows
422
+ which session the page is for, so it writes the description into the page.
423
+
424
+ Returns ``"null"`` whenever the answer is not already known (no sid, still
425
+ loading, or anything unexpected); the viewer then asks the way it always
426
+ has. This must never wait for a session: a pending one is exactly the case
427
+ the WebSocket push handles better.
428
+ """
429
+ if not sid:
430
+ return "null"
431
+ try:
432
+ if sid in _session_mod.PENDING_SESSIONS:
433
+ return "null"
434
+ session = SESSIONS.get(sid)
435
+ if session is None:
436
+ return "null"
437
+ from arrayview._analysis import _build_metadata
438
+
439
+ return json.dumps(_build_metadata(session))
440
+ except Exception:
441
+ return "null"
442
+
443
+
431
444
  def _viewer_ui_response(
432
445
  request: Request,
433
446
  *,
434
447
  sid: str | None,
435
448
  query_val: str,
449
+ asset_base: str = "",
436
450
  ):
437
- """Render one viewer page with its launch query already chosen."""
451
+ """Render one viewer page with its launch query already chosen.
452
+
453
+ ``asset_base`` is prepended to the page's script addresses. The plain
454
+ route keeps them relative so the page also works behind a Jupyter proxy
455
+ prefix; the private tunnel route passes "/" so the addresses are the
456
+ same on every launch and the browser can reuse the cached script
457
+ (relative addresses under /_av/<tab>/... change with every tab key, which
458
+ made the built-in browser download the 400 KB script on every open).
459
+ """
438
460
  _init_luts()
439
461
  _cfg_colormaps = get_viewer_colormaps()
440
462
  _valid_cfg_colormaps = (
@@ -451,12 +473,13 @@ def _viewer_ui_response(
451
473
  _default_ortho_layout = json.dumps(get_viewer_ortho_layout())
452
474
  _default_dimbar_mode = json.dumps(get_viewer_dimbar_mode())
453
475
  html = (
454
- _VIEWER_HTML_TEMPLATE.replace("__COLORMAPS__", str(_active_colormaps))
476
+ _VIEWER_PAGE_TEMPLATE.replace("__COLORMAPS__", str(_active_colormaps))
455
477
  .replace("__COLORMAP_GRADIENT_STOPS__", json.dumps(COLORMAP_GRADIENT_STOPS))
456
478
  .replace("__LABEL_COLORS__", json.dumps(LABEL_COLORS.astype(int).tolist()))
457
479
  .replace("__COMPLEX_MODES__", str(COMPLEX_MODES))
458
480
  .replace("__REAL_MODES__", str(REAL_MODES))
459
481
  .replace("__ARRAYVIEW_QUERY__", query_val)
482
+ .replace("__ARRAYVIEW_BOOT_METADATA__", _boot_metadata_json(sid))
460
483
  .replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
461
484
  .replace("__DEFAULT_ROUNDED_PANES__", _default_rounded_panes)
462
485
  .replace("__DEFAULT_ORTHO_LAYOUT__", _default_ortho_layout)
@@ -464,6 +487,9 @@ def _viewer_ui_response(
464
487
  .replace("__BODY_CLASS__", "av-loading" if sid else "")
465
488
  .replace("__ARRAYVIEW_VERSION__", _av_version)
466
489
  )
490
+ if asset_base:
491
+ html = html.replace('<script src="gsap.min.js">', f'<script src="{asset_base}gsap.min.js">')
492
+ html = html.replace('<script src="viewer-', f'<script src="{asset_base}viewer-')
467
493
  return _text_response(
468
494
  html,
469
495
  request=request,
@@ -479,6 +505,15 @@ def get_short_viewer_ui(
479
505
  navigation_key: str,
480
506
  ):
481
507
  """Resolve a private integrated-browser launch without exposing its query."""
508
+ # The page's script tags use relative addresses so they also work behind
509
+ # a Jupyter proxy prefix. Under this nested route the browser resolves
510
+ # them to /_av/<tab_key>/<asset>, so the assets are answered here too.
511
+ # (Before this, gsap.min.js silently 404'd on every tunnel launch and the
512
+ # viewer ran without its animation library.)
513
+ if navigation_key == "gsap.min.js":
514
+ return serve_gsap(request)
515
+ if navigation_key.startswith("viewer-") and navigation_key.endswith(".js"):
516
+ return serve_viewer_static_js(navigation_key[len("viewer-"):-3], request)
482
517
  try:
483
518
  from arrayview._launch_trace import emit_route_launch_event
484
519
 
@@ -535,6 +570,7 @@ def get_short_viewer_ui(
535
570
  request,
536
571
  sid=sid,
537
572
  query_val=json.dumps(bootstrap_query),
573
+ asset_base="/",
538
574
  )
539
575
 
540
576