arrayview 0.42.0__tar.gz → 0.42.2__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 (112) hide show
  1. {arrayview-0.42.0 → arrayview-0.42.2}/PKG-INFO +1 -1
  2. {arrayview-0.42.0 → arrayview-0.42.2}/pyproject.toml +1 -1
  3. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_io.py +39 -3
  4. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_launch_trace.py +59 -2
  5. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_loading.py +12 -0
  6. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_query.py +44 -0
  7. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_websocket.py +57 -3
  8. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_server.py +22 -0
  9. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_session.py +4 -0
  10. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_viewer.html +30 -2
  11. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_vscode_extension.py +1 -1
  12. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_vscode_signal.py +52 -13
  13. arrayview-0.42.2/src/arrayview/arrayview-opener.vsix +0 -0
  14. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_api.py +4 -2
  15. arrayview-0.42.2/tests/test_launch_trace.py +231 -0
  16. arrayview-0.42.0/src/arrayview/arrayview-opener.vsix +0 -0
  17. arrayview-0.42.0/tests/test_launch_trace.py +0 -99
  18. {arrayview-0.42.0 → arrayview-0.42.2}/.gitignore +0 -0
  19. {arrayview-0.42.0 → arrayview-0.42.2}/LICENSE +0 -0
  20. {arrayview-0.42.0 → arrayview-0.42.2}/README.md +0 -0
  21. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/ARCHITECTURE.md +0 -0
  22. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/__init__.py +0 -0
  23. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/__main__.py +0 -0
  24. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_analysis.py +0 -0
  25. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_app.py +0 -0
  26. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_config.py +0 -0
  27. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_diagnostics.py +0 -0
  28. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_dicom.py +0 -0
  29. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_diff.py +0 -0
  30. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_icon.png +0 -0
  31. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_imaging.py +0 -0
  32. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_instance_registry.py +0 -0
  33. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_launch_plan.py +0 -0
  34. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_launcher.py +0 -0
  35. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_lifecycle.py +0 -0
  36. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_overlays.py +0 -0
  37. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_platform.py +0 -0
  38. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_render.py +0 -0
  39. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_analysis.py +0 -0
  40. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_drop.py +0 -0
  41. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_export.py +0 -0
  42. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_persistence.py +0 -0
  43. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_preferences.py +0 -0
  44. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_preload.py +0 -0
  45. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_rendering.py +0 -0
  46. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_segmentation.py +0 -0
  47. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_state.py +0 -0
  48. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_routes_vectorfield.py +0 -0
  49. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_segmentation.py +0 -0
  50. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_session_spec.py +0 -0
  51. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_shell.html +0 -0
  52. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_source_safety.py +0 -0
  53. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_synthetic_mri.py +0 -0
  54. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_torch.py +0 -0
  55. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_tutorial.py +0 -0
  56. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_vectorfield.py +0 -0
  57. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_vscode.py +0 -0
  58. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_vscode_browser.py +0 -0
  59. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/_watch.py +0 -0
  60. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/gsap.min.js +0 -0
  61. {arrayview-0.42.0 → arrayview-0.42.2}/src/arrayview/todo.md +0 -0
  62. {arrayview-0.42.0 → arrayview-0.42.2}/tests/capture_patient_loading.py +0 -0
  63. {arrayview-0.42.0 → arrayview-0.42.2}/tests/capture_v_animation.py +0 -0
  64. {arrayview-0.42.0 → arrayview-0.42.2}/tests/conftest.py +0 -0
  65. {arrayview-0.42.0 → arrayview-0.42.2}/tests/lifecycle_matrix.py +0 -0
  66. {arrayview-0.42.0 → arrayview-0.42.2}/tests/make_vectorfield_test_arrays.py +0 -0
  67. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_1d_arrays.py +0 -0
  68. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_backend_shared.py +0 -0
  69. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_browser.py +0 -0
  70. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_cli.py +0 -0
  71. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_colorbar_hover_highlight.py +0 -0
  72. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_config.py +0 -0
  73. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_dicom.py +0 -0
  74. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_display_opener_results.py +0 -0
  75. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_doctor_commands.py +0 -0
  76. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_drop_import.py +0 -0
  77. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_instance_registry.py +0 -0
  78. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_invocation_contract_matrix.py +0 -0
  79. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_io.py +0 -0
  80. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_large_arrays.py +0 -0
  81. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_launch_blackbox.py +0 -0
  82. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_launch_diagnostics.py +0 -0
  83. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_launch_execution_ownership.py +0 -0
  84. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_launch_plan.py +0 -0
  85. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_lifecycle_contract.py +0 -0
  86. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_loading_server.py +0 -0
  87. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_mode_consistency.py +0 -0
  88. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_mode_matrix.py +0 -0
  89. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_mode_roundtrip.py +0 -0
  90. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_nifti_meta.py +0 -0
  91. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_nifti_series.py +0 -0
  92. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_preferences_ui.py +0 -0
  93. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_public_invocation_regressions.py +0 -0
  94. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_render_failure_reporting.py +0 -0
  95. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_render_progress.py +0 -0
  96. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_rgb_pixel_art.py +0 -0
  97. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_safe_server_stop.py +0 -0
  98. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_server_identity_status.py +0 -0
  99. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_session_spec.py +0 -0
  100. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_source_safety.py +0 -0
  101. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_spawn_ownership.py +0 -0
  102. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_torch.py +0 -0
  103. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_tutorial.py +0 -0
  104. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_tutorial_browser.py +0 -0
  105. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_value_formatting.py +0 -0
  106. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_view_handle_lifecycle.py +0 -0
  107. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_view_launch_execution.py +0 -0
  108. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_vscode_ack_protocol.py +0 -0
  109. {arrayview-0.42.0 → arrayview-0.42.2}/tests/test_window_level_preview.py +0 -0
  110. {arrayview-0.42.0 → arrayview-0.42.2}/tests/ui_audit.py +0 -0
  111. {arrayview-0.42.0 → arrayview-0.42.2}/tests/v_anim_frames/.gitkeep +0 -0
  112. {arrayview-0.42.0 → arrayview-0.42.2}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.42.0
3
+ Version: 0.42.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.42.0"
7
+ version = "0.42.2"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -1521,7 +1521,41 @@ def mmap_first_frame_is_cheap(arr, total_bytes) -> bool:
1521
1521
  return runs <= max_runs and faulted <= fraction * total_bytes
1522
1522
 
1523
1523
 
1524
- def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select=None):
1524
+ def _read_npy_reporting_progress(mapped, progress):
1525
+ """Copy a memmapped .npy into RAM in chunks, saying how far along it is.
1526
+
1527
+ For a large array this read is the whole wait between a click and the
1528
+ first frame — 9 s for a 1.6 GB file on a cold cache — and ``np.load``
1529
+ reports nothing while it runs. Copying in pieces costs the same reads
1530
+ and makes the wait explainable.
1531
+
1532
+ The flat view follows the array's own memory order, so the copy stays
1533
+ sequential whether the file is C- or Fortran-ordered. A .npy memmap is
1534
+ always contiguous in one of the two, so both reshapes are views.
1535
+ """
1536
+ order = "F" if mapped.flags.f_contiguous and not mapped.flags.c_contiguous else "C"
1537
+ out = np.empty(mapped.shape, dtype=mapped.dtype, order=order)
1538
+ src = mapped.reshape(-1, order="A")
1539
+ dst = out.reshape(-1, order="A")
1540
+ total = int(src.shape[0])
1541
+ itemsize = int(mapped.dtype.itemsize)
1542
+ # ~200 updates: smooth enough to read as motion, coarse enough that the
1543
+ # per-chunk overhead stays lost in the read itself.
1544
+ step = max(1, total // 200)
1545
+ done = 0
1546
+ while done < total:
1547
+ end = min(done + step, total)
1548
+ dst[done:end] = src[done:end]
1549
+ done = end
1550
+ try:
1551
+ progress(done * itemsize, total * itemsize)
1552
+ except Exception:
1553
+ pass
1554
+ return out
1555
+
1556
+
1557
+ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select=None,
1558
+ progress=None):
1525
1559
  """Like load_data but also returns spatial metadata for NIfTI files.
1526
1560
 
1527
1561
  Returns (array, meta_or_None). meta is None for non-NIfTI formats.
@@ -1547,10 +1581,10 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
1547
1581
  return _load_file_series(filepath, load=load, stack=stack)
1548
1582
  if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
1549
1583
  return _load_nifti_with_meta(filepath)
1550
- return load_data(filepath, key=key), None
1584
+ return load_data(filepath, key=key, progress=progress), None
1551
1585
 
1552
1586
 
1553
- def load_data(filepath, key=None):
1587
+ def load_data(filepath, key=None, *, progress=None):
1554
1588
  if filepath.lower().endswith(".dcm"):
1555
1589
  from ._dicom import load_dicom_series
1556
1590
 
@@ -1577,6 +1611,8 @@ def load_data(filepath, key=None):
1577
1611
  if mmap_first_frame_is_cheap(mapped, size):
1578
1612
  # Session creation upgrades this to an in-RAM array in the background.
1579
1613
  return mapped
1614
+ if progress is not None:
1615
+ return _read_npy_reporting_progress(mapped, progress)
1580
1616
  del mapped
1581
1617
  return np.load(filepath)
1582
1618
  elif filepath.endswith(".npz"):
@@ -1,8 +1,10 @@
1
1
  """Best-effort, opt-in launch event tracing.
2
2
 
3
3
  The trace is deliberately dependency-free and inert unless
4
- ``ARRAYVIEW_LAUNCH_TRACE`` names an absolute JSONL file. Launch behavior must
5
- never depend on a trace write succeeding.
4
+ ``ARRAYVIEW_LAUNCH_TRACE`` names an absolute JSONL file. Integrated-browser
5
+ route tracing can also be enabled explicitly with
6
+ ``~/.arrayview/enable-launch-trace``. Launch behavior must never depend on a
7
+ trace write succeeding.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -20,6 +22,8 @@ TRACE_PATH_ENV = "ARRAYVIEW_LAUNCH_TRACE"
20
22
  TRACE_ID_ENV = "ARRAYVIEW_LAUNCH_ID"
21
23
  TRACE_ROLE_ENV = "ARRAYVIEW_LAUNCH_ROLE"
22
24
  TRACE_SCHEMA = 1
25
+ TRACE_MARKER_NAME = "enable-launch-trace"
26
+ TRACE_DEFAULT_NAME = "launch-trace.jsonl"
23
27
  _MAX_EVENT_BYTES = 4096
24
28
 
25
29
  _lock = threading.Lock()
@@ -70,6 +74,56 @@ def trace_tag(value: object) -> str:
70
74
  return hashlib.sha256(str(value).encode("utf-8", "replace")).hexdigest()[:12]
71
75
 
72
76
 
77
+ def configure_route_launch_trace() -> bool:
78
+ """Enable route tracing from an explicit environment or marker opt-in.
79
+
80
+ Route handlers call this directly so an already-running server can notice
81
+ the marker even when its process environment never contained the trace
82
+ variable. An explicit environment value always wins, including an invalid
83
+ relative value, which keeps the normal trace contract unambiguous.
84
+ """
85
+ try:
86
+ explicit = os.environ.get(TRACE_PATH_ENV)
87
+ if explicit is not None:
88
+ if not explicit or not Path(explicit).is_absolute():
89
+ return False
90
+ if _path != explicit:
91
+ configure_launch_trace()
92
+ return _path == explicit
93
+
94
+ trace_dir = Path.home() / ".arrayview"
95
+ if not (trace_dir / TRACE_MARKER_NAME).is_file():
96
+ return False
97
+ candidate = str(trace_dir / TRACE_DEFAULT_NAME)
98
+ if _path != candidate:
99
+ configure_launch_trace(path=candidate, role="server")
100
+ return _path == candidate
101
+ except Exception:
102
+ return False
103
+
104
+
105
+ def emit_route_launch_event(
106
+ event: str,
107
+ *,
108
+ navigation_key: str,
109
+ tab_key: str | None = None,
110
+ **attrs: object,
111
+ ) -> None:
112
+ """Emit a route event without exposing private route credentials."""
113
+ try:
114
+ if not configure_route_launch_trace():
115
+ return
116
+ safe_attrs = {
117
+ "navigation_key_tag": trace_tag(navigation_key),
118
+ **attrs,
119
+ }
120
+ if tab_key is not None:
121
+ safe_attrs["tab_key_tag"] = trace_tag(tab_key)
122
+ emit_launch_event(event, **safe_attrs)
123
+ except Exception:
124
+ return
125
+
126
+
73
127
  def emit_launch_event(event: str, **attrs: object) -> None:
74
128
  """Append one compact event, swallowing every tracing-related failure."""
75
129
  try:
@@ -113,10 +167,13 @@ def _ensure_environment_configuration() -> None:
113
167
 
114
168
  __all__ = [
115
169
  "TRACE_ID_ENV",
170
+ "TRACE_MARKER_NAME",
116
171
  "TRACE_PATH_ENV",
117
172
  "TRACE_ROLE_ENV",
118
173
  "configure_launch_trace",
174
+ "configure_route_launch_trace",
119
175
  "emit_launch_event",
176
+ "emit_route_launch_event",
120
177
  "trace_child_environment",
121
178
  "trace_tag",
122
179
  ]
@@ -320,6 +320,14 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
320
320
  PENDING_SESSION_EVENTS[sid] = pending_event
321
321
  signature_before_load = file_signature(abs_path)
322
322
 
323
+ def _report_read_progress(done: int, total: int) -> None:
324
+ # Read from the viewer's websocket while it waits; see
325
+ # LOAD_PROGRESS. Kept to a plain assignment so the read loop
326
+ # pays nothing for it.
327
+ import arrayview._session as _sm
328
+
329
+ _sm.LOAD_PROGRESS[sid] = (done, total)
330
+
323
331
  def _load_in_background() -> None:
324
332
  try:
325
333
  from ._io import load_data_with_meta
@@ -327,6 +335,7 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
327
335
  data, spatial_meta = load_data_with_meta(
328
336
  filepath,
329
337
  select=body.get("select"),
338
+ progress=_report_read_progress,
330
339
  )
331
340
  session = Session(data, filepath=filepath, name=name)
332
341
  session.sid = sid
@@ -369,6 +378,9 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
369
378
  if staging_dir:
370
379
  cleanup_staging_directory(staging_dir)
371
380
  finally:
381
+ import arrayview._session as _sm
382
+
383
+ _sm.LOAD_PROGRESS.pop(sid, None)
372
384
  PENDING_SESSIONS.discard(sid)
373
385
  CANCELLED_PENDING_SESSIONS.discard(sid)
374
386
  pending_event.set()
@@ -140,6 +140,20 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
140
140
  previous_navigation_key,
141
141
  None,
142
142
  )
143
+ try:
144
+ from arrayview._launch_trace import emit_route_launch_event
145
+
146
+ emit_route_launch_event(
147
+ "page.route_retired",
148
+ navigation_key=previous_navigation_key,
149
+ tab_key=previous_journal.get("tab_key"),
150
+ request_id=request_id,
151
+ navigation_attempt=previous_journal.get(
152
+ "navigation_attempt"
153
+ ),
154
+ )
155
+ except Exception:
156
+ pass
143
157
  journal = journals[request_id] = {
144
158
  "token": token,
145
159
  "window_id": window_id,
@@ -159,6 +173,18 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
159
173
  sid,
160
174
  request_id,
161
175
  )
176
+ try:
177
+ from arrayview._launch_trace import emit_route_launch_event
178
+
179
+ emit_route_launch_event(
180
+ "page.route_prepared",
181
+ navigation_key=navigation_key,
182
+ tab_key=tab_key,
183
+ request_id=request_id,
184
+ navigation_attempt=navigation_attempt,
185
+ )
186
+ except Exception:
187
+ pass
162
188
  return {
163
189
  "sid": sid,
164
190
  "request_id": request_id,
@@ -191,6 +217,24 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
191
217
  phases = journal["phases"]
192
218
  if phase not in phases:
193
219
  phases.append(phase)
220
+ # Timestamps the page's own milestones against the navigation that
221
+ # delivered it, so time-to-first-script can be measured per
222
+ # navigation rather than inferred from opener poll intervals.
223
+ navigation_key = journal.get("navigation_key")
224
+ if navigation_key:
225
+ try:
226
+ from arrayview._launch_trace import emit_route_launch_event
227
+
228
+ emit_route_launch_event(
229
+ "page.phase_recorded",
230
+ navigation_key=navigation_key,
231
+ tab_key=journal.get("tab_key"),
232
+ request_id=request_id,
233
+ navigation_attempt=journal.get("navigation_attempt"),
234
+ phase=phase,
235
+ )
236
+ except Exception:
237
+ pass
194
238
  if bool(body.get("release_on_disconnect")):
195
239
  journal["disconnect_release_grace_seconds"] = 30.0
196
240
  return {
@@ -111,6 +111,42 @@ def _render_progress_reporter(
111
111
  return report
112
112
 
113
113
 
114
+ async def _await_session_reporting_read_progress(ws, sid):
115
+ """Wait for a background read, telling the viewer how far along it is.
116
+
117
+ Nothing else can report this: the reader is a plain thread and the array
118
+ is not a session until it finishes, so this socket is the only thing
119
+ holding a line to the viewer while the wait happens.
120
+
121
+ Silent for the first stretch, so an ordinary fast open never flashes a
122
+ bar on its way past — the same rule the render progress reporter uses.
123
+ """
124
+ quiet_for_s = 0.6
125
+ started = time.perf_counter()
126
+ waiter = asyncio.ensure_future(wait_for_session_ready(sid))
127
+ last = None
128
+ while True:
129
+ done, _pending = await asyncio.wait({waiter}, timeout=0.15)
130
+ if waiter in done:
131
+ break
132
+ if time.perf_counter() - started < quiet_for_s:
133
+ continue
134
+ state = _session_mod.LOAD_PROGRESS.get(sid)
135
+ if state and state != last:
136
+ last = state
137
+ read, total = state
138
+ await _send_json_quietly(ws, {
139
+ "type": "render_progress",
140
+ "done": int(read),
141
+ "total": int(total),
142
+ "label": "Reading array",
143
+ })
144
+ try:
145
+ return waiter.result()
146
+ except Exception:
147
+ return _session_mod.SESSIONS.get(sid)
148
+
149
+
114
150
  async def _send_json_quietly(ws, payload):
115
151
  try:
116
152
  await ws.send_json(payload)
@@ -190,8 +226,28 @@ def register_websocket_routes(app) -> None:
190
226
  native_request_id = ws.query_params.get("native_request_id")
191
227
  launch_request_id = ws.query_params.get("launch_request_id")
192
228
  launch_token = ws.query_params.get("launch_token")
193
- session = await wait_for_session_ready(sid)
229
+ # Answer the handshake before the array is ready. Holding it closed
230
+ # until the load finished made the viewer decide the backend was dead:
231
+ # it gives up after three attempts and shows "could not connect", which
232
+ # a large array reaches purely by being slow to load. Accepting first
233
+ # stops that clock. The viewer keeps its splash up until the metadata
234
+ # below arrives, so an early accept shows nothing half-rendered.
235
+ await ws.accept()
236
+
237
+ session = _session_mod.SESSIONS.get(sid)
238
+ if session is None:
239
+ session = await _await_session_reporting_read_progress(ws, sid)
194
240
  if not session:
241
+ # The socket has already opened, and the viewer treats a drop after
242
+ # a successful open as a recoverable reconnect — it would retry
243
+ # forever. Say the load failed so it shows an error instead.
244
+ try:
245
+ await ws.send_json({
246
+ "type": "render_error",
247
+ "message": "the array could not be loaded",
248
+ })
249
+ except Exception:
250
+ pass
195
251
  await ws.close()
196
252
  return
197
253
 
@@ -208,8 +264,6 @@ def register_websocket_routes(app) -> None:
208
264
  await ws.close(code=1008)
209
265
  return
210
266
 
211
- await ws.accept()
212
-
213
267
  # A new viewer proves that the prior disconnect was a reload, tunnel
214
268
  # interruption, or other recoverable transport break. Invalidate and
215
269
  # cancel its cleanup before recording this connection.
@@ -422,6 +422,16 @@ def get_short_viewer_ui(
422
422
  navigation_key: str,
423
423
  ):
424
424
  """Resolve a private integrated-browser launch without exposing its query."""
425
+ try:
426
+ from arrayview._launch_trace import emit_route_launch_event
427
+
428
+ emit_route_launch_event(
429
+ "page.route_entered",
430
+ navigation_key=navigation_key,
431
+ tab_key=tab_key,
432
+ )
433
+ except Exception:
434
+ pass
425
435
  owner = _session_mod.VIEWER_LAUNCH_ROUTES.get(navigation_key)
426
436
  if owner is None:
427
437
  raise HTTPException(status_code=404, detail="Viewer launch route expired")
@@ -437,6 +447,18 @@ def get_short_viewer_ui(
437
447
  or journal.get("navigation_key") != navigation_key
438
448
  ):
439
449
  raise HTTPException(status_code=409, detail="Viewer launch route changed")
450
+ try:
451
+ from arrayview._launch_trace import emit_route_launch_event
452
+
453
+ emit_route_launch_event(
454
+ "page.route_resolved",
455
+ navigation_key=navigation_key,
456
+ tab_key=tab_key,
457
+ request_id=request_id,
458
+ navigation_attempt=journal["navigation_attempt"],
459
+ )
460
+ except Exception:
461
+ pass
440
462
  bootstrap_query = journal["viewer_query"] + "&" + urlencode(
441
463
  {
442
464
  "_av_integrated_browser": "1",
@@ -51,6 +51,10 @@ CANCELLED_PENDING_SESSIONS: set[str] = set()
51
51
  # without this the opener cannot tell "still loading" from "will never load" and
52
52
  # waits out its whole deadline on a file that failed in the first second.
53
53
  FAILED_PENDING_SESSIONS: dict[str, str] = {}
54
+ # sid -> (bytes read, bytes total) for a background read still in progress.
55
+ # The reader is a plain thread with no socket of its own, so it leaves its
56
+ # progress here and the viewer's websocket picks it up while it waits.
57
+ LOAD_PROGRESS: dict[str, tuple[int, int]] = {}
54
58
 
55
59
 
56
60
  def file_signature(filepath: str):
@@ -2,6 +2,24 @@
2
2
  <html>
3
3
  <head>
4
4
  <title>ArrayView</title>
5
+ <script>
6
+ // Name the tab at parse time. Without this the tab shows the host
7
+ // ("localhost:8000") until metadata arrives over the WebSocket, and
8
+ // that wait is the array load — seconds for a large file. The name
9
+ // rides in on ?av_name= because the hash already carries #av-<id>.
10
+ // Formatting must match _setWindowIdentity(), or the title changes a
11
+ // second time when metadata lands.
12
+ (function () {
13
+ try {
14
+ var name = new URLSearchParams(location.search).get('av_name');
15
+ if (!name) return;
16
+ var clean = String(name).trim();
17
+ document.title = clean && clean !== 'welcome'
18
+ ? 'ArrayView: ' + clean
19
+ : 'ArrayView';
20
+ } catch (_) {}
21
+ })();
22
+ </script>
5
23
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='2.5' fill='%230c0c0c'/%3E%3Crect x='2' y='2' width='3' height='3' fill='%233a0ca3'/%3E%3Crect x='6.5' y='2' width='3' height='3' fill='%23560bad'/%3E%3Crect x='11' y='2' width='3' height='3' fill='%23c77dff'/%3E%3Crect x='2' y='6.5' width='3' height='3' fill='%234361ee'/%3E%3Crect x='6.5' y='6.5' width='3' height='3' fill='%234cc9f0'/%3E%3Crect x='11' y='6.5' width='3' height='3' fill='%23f5c842'/%3E%3Crect x='2' y='11' width='3' height='3' fill='%234895ef'/%3E%3Crect x='6.5' y='11' width='3' height='3' fill='%2380ed99'/%3E%3Crect x='11' y='11' width='3' height='3' fill='%23f8961e'/%3E%3C/svg%3E">
6
24
  <style>
7
25
  /* ── CSS: Theme Variables and Base Layout ────────────────────── */
@@ -5552,8 +5570,11 @@
5552
5570
  // never need to appear in the browser toolbar.
5553
5571
  const _arrayViewLaunchSearch = __ARRAYVIEW_QUERY__ || window.location.search;
5554
5572
  const urlParams = new URLSearchParams(_arrayViewLaunchSearch);
5555
- const _integratedBrowserDisplayHash = (
5573
+ const _isIntegratedBrowserViewer = (
5556
5574
  urlParams.get('_av_integrated_browser') === '1'
5575
+ );
5576
+ const _integratedBrowserDisplayHash = (
5577
+ _isIntegratedBrowserViewer
5557
5578
  && window.location.hash.startsWith('#av-')
5558
5579
  ) ? window.location.hash : '';
5559
5580
  // isInlineEmbed: true when explicitly launched as an inline Jupyter/notebook embed
@@ -15485,7 +15506,14 @@
15485
15506
 
15486
15507
  // Shell-lite: when viewer is top-level (not in shell iframe), listen for
15487
15508
  // new-tab signals so CLI tab injection still works.
15488
- if (window === window.top) {
15509
+ //
15510
+ // Skipped inside a VS Code browser tab. Every such tab holds its
15511
+ // sockets open through one shared VS Code channel that stops
15512
+ // carrying new page requests at ~32 of them, so a second socket per
15513
+ // viewer halves how many arrays can be open before opening breaks.
15514
+ // Nothing is lost: each launch opens its own VS Code tab, so the
15515
+ // window.open path this socket exists to serve never runs there.
15516
+ if (window === window.top && !_isIntegratedBrowserViewer) {
15489
15517
  try {
15490
15518
  const _shellProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
15491
15519
  const _shellWs = new WebSocket(`${_shellProto}//${location.host}${resolveServerPath('/ws/shell')}`);
@@ -57,7 +57,7 @@ _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
57
57
  _VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
58
58
  _VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
59
59
  _VSCODE_EXT_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
60
- _VSCODE_EXT_VERSION = "0.15.18" # current bundled extension version
60
+ _VSCODE_EXT_VERSION = "0.15.23" # current bundled extension version
61
61
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
62
62
  # Version-skew notices already printed, so a long-lived process (Jupyter, a
63
63
  # script opening several arrays) states the mismatch once instead of per call.
@@ -384,6 +384,46 @@ def _find_arrayview_window_id() -> str | None:
384
384
  return None
385
385
 
386
386
 
387
+ def _session_name_for_url(url: str) -> str | None:
388
+ """Return the session name behind *url*'s ``?sid=``, or None."""
389
+ try:
390
+ import urllib.parse as _up
391
+
392
+ sid = _up.parse_qs(_up.urlparse(url).query).get("sid", [None])[0]
393
+ if not sid:
394
+ return None
395
+ import arrayview._session as _sm
396
+
397
+ session = _sm.SESSIONS.get(sid)
398
+ return getattr(session, "name", None) if session else None
399
+ except Exception:
400
+ return None
401
+
402
+
403
+ def _with_viewer_name(url: str, name: str | None) -> str:
404
+ """Add ``av_name`` so the viewer can title its tab before metadata lands.
405
+
406
+ The page otherwise shows the bare host until the WebSocket delivers the
407
+ session name, and that WebSocket waits on the array load — seconds for a
408
+ large file. This stays a query parameter rather than being baked into the
409
+ served HTML because the gzip cache in ``_server`` is keyed on the page
410
+ body; a per-session body would evict on every open and re-compress ~2 MB.
411
+ """
412
+ if not name:
413
+ return url
414
+ try:
415
+ import urllib.parse as _up
416
+
417
+ parts = _up.urlsplit(url)
418
+ query = _up.parse_qsl(parts.query, keep_blank_values=True)
419
+ if any(key == "av_name" for key, _ in query):
420
+ return url
421
+ query.append(("av_name", str(name)))
422
+ return _up.urlunsplit(parts._replace(query=_up.urlencode(query)))
423
+ except Exception:
424
+ return url
425
+
426
+
387
427
  def _open_via_signal_file(
388
428
  url: str,
389
429
  delay: float = 0.0,
@@ -402,19 +442,13 @@ def _open_via_signal_file(
402
442
  If omitted it is auto-derived from the session name embedded in the URL's
403
443
  ``?sid=`` query parameter.
404
444
  """
405
- if title is None:
406
- try:
407
- import urllib.parse as _up
408
-
409
- _sid = _up.parse_qs(_up.urlparse(url).query).get("sid", [None])[0]
410
- if _sid:
411
- import arrayview._session as _sm
412
-
413
- _sess = _sm.SESSIONS.get(_sid)
414
- if _sess:
415
- title = f"ArrayView: {_sess.name}"
416
- except Exception:
417
- pass
445
+ session_name = _session_name_for_url(url)
446
+ if title is None and session_name:
447
+ title = f"ArrayView: {session_name}"
448
+ # The viewer titles its own tab from this, so it must be the session name
449
+ # the metadata will later carry — not *title*, which some callers pass
450
+ # pre-formatted and others pass as a bare filename.
451
+ url = _with_viewer_name(url, session_name)
418
452
 
419
453
  request_id = uuid.uuid4().hex
420
454
  if window_id is None:
@@ -454,6 +488,11 @@ def _open_via_signal_file(
454
488
  payload["title"] = title
455
489
  if handoff_path := os.environ.get("ARRAYVIEW_HANDOFF_PATH"):
456
490
  payload["handoffPath"] = handoff_path
491
+ # Opt-in measurement launch: the opener observes the navigation for a long
492
+ # budget instead of closing and reopening a blank tab after 1.5 s. Only
493
+ # this launch is affected, so ordinary use continues alongside a run.
494
+ if os.environ.get("ARRAYVIEW_MEASURE_NAVIGATION") == "1":
495
+ payload["measureNavigation"] = True
457
496
  if floating:
458
497
  payload["floating"] = True
459
498
  if remote_was_supplied:
@@ -346,7 +346,8 @@ class TestLoad:
346
346
  started = threading.Event()
347
347
  finish = threading.Event()
348
348
 
349
- def slow_load(filepath, key=None, select=None, *, load="lazy", stack="auto"):
349
+ def slow_load(filepath, key=None, select=None, *, load="lazy",
350
+ stack="auto", progress=None):
350
351
  started.set()
351
352
  assert finish.wait(2.0)
352
353
  return np.zeros((4, 4), dtype=np.float32), None
@@ -390,7 +391,8 @@ class TestLoad:
390
391
  started = threading.Event()
391
392
  finish = threading.Event()
392
393
 
393
- def slow_load(filepath, key=None, select=None, *, load="lazy", stack="auto"):
394
+ def slow_load(filepath, key=None, select=None, *, load="lazy",
395
+ stack="auto", progress=None):
394
396
  started.set()
395
397
  assert finish.wait(2.0)
396
398
  return np.zeros((4, 4), dtype=np.float32), None