arrayview 0.42.2__tar.gz → 0.43.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 (112) hide show
  1. {arrayview-0.42.2 → arrayview-0.43.1}/PKG-INFO +2 -2
  2. {arrayview-0.42.2 → arrayview-0.43.1}/pyproject.toml +1 -1
  3. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_diagnostics.py +20 -2
  4. arrayview-0.43.1/src/arrayview/_extra_ports.py +181 -0
  5. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_io.py +5 -5
  6. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_launcher.py +41 -15
  7. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_loading.py +23 -22
  8. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_rendering.py +16 -1
  9. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_vectorfield.py +20 -19
  10. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_websocket.py +102 -0
  11. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_server.py +22 -0
  12. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_session.py +17 -1
  13. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_source_safety.py +8 -0
  14. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_viewer.html +1191 -89
  15. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_vscode_browser.py +9 -0
  16. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_vscode_extension.py +1 -1
  17. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_vscode_signal.py +49 -0
  18. arrayview-0.43.1/src/arrayview/arrayview-opener.vsix +0 -0
  19. {arrayview-0.42.2 → arrayview-0.43.1}/tests/lifecycle_matrix.py +1 -0
  20. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_api.py +6 -3
  21. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_browser.py +125 -8
  22. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_cli.py +10 -10
  23. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_launch_diagnostics.py +4 -0
  24. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_lifecycle_contract.py +45 -0
  25. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_mode_matrix.py +75 -0
  26. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_mode_roundtrip.py +13 -6
  27. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_nifti_series.py +3 -3
  28. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_source_safety.py +5 -0
  29. arrayview-0.42.2/src/arrayview/arrayview-opener.vsix +0 -0
  30. {arrayview-0.42.2 → arrayview-0.43.1}/.gitignore +0 -0
  31. {arrayview-0.42.2 → arrayview-0.43.1}/LICENSE +0 -0
  32. {arrayview-0.42.2 → arrayview-0.43.1}/README.md +0 -0
  33. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/ARCHITECTURE.md +0 -0
  34. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/__init__.py +0 -0
  35. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/__main__.py +0 -0
  36. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_analysis.py +0 -0
  37. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_app.py +0 -0
  38. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_config.py +0 -0
  39. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_dicom.py +0 -0
  40. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_diff.py +0 -0
  41. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_icon.png +0 -0
  42. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_imaging.py +0 -0
  43. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_instance_registry.py +0 -0
  44. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_launch_plan.py +0 -0
  45. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_launch_trace.py +0 -0
  46. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_lifecycle.py +0 -0
  47. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_overlays.py +0 -0
  48. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_platform.py +0 -0
  49. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_render.py +0 -0
  50. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_analysis.py +0 -0
  51. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_drop.py +0 -0
  52. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_export.py +0 -0
  53. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_persistence.py +0 -0
  54. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_preferences.py +0 -0
  55. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_preload.py +0 -0
  56. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_query.py +0 -0
  57. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_segmentation.py +0 -0
  58. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_routes_state.py +0 -0
  59. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_segmentation.py +0 -0
  60. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_session_spec.py +0 -0
  61. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_shell.html +0 -0
  62. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_synthetic_mri.py +0 -0
  63. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_torch.py +0 -0
  64. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_tutorial.py +0 -0
  65. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_vectorfield.py +0 -0
  66. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_vscode.py +0 -0
  67. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/_watch.py +0 -0
  68. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/gsap.min.js +0 -0
  69. {arrayview-0.42.2 → arrayview-0.43.1}/src/arrayview/todo.md +0 -0
  70. {arrayview-0.42.2 → arrayview-0.43.1}/tests/capture_patient_loading.py +0 -0
  71. {arrayview-0.42.2 → arrayview-0.43.1}/tests/capture_v_animation.py +0 -0
  72. {arrayview-0.42.2 → arrayview-0.43.1}/tests/conftest.py +0 -0
  73. {arrayview-0.42.2 → arrayview-0.43.1}/tests/make_vectorfield_test_arrays.py +0 -0
  74. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_1d_arrays.py +0 -0
  75. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_backend_shared.py +0 -0
  76. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_colorbar_hover_highlight.py +0 -0
  77. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_config.py +0 -0
  78. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_dicom.py +0 -0
  79. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_display_opener_results.py +0 -0
  80. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_doctor_commands.py +0 -0
  81. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_drop_import.py +0 -0
  82. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_instance_registry.py +0 -0
  83. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_invocation_contract_matrix.py +0 -0
  84. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_io.py +0 -0
  85. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_large_arrays.py +0 -0
  86. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_launch_blackbox.py +0 -0
  87. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_launch_execution_ownership.py +0 -0
  88. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_launch_plan.py +0 -0
  89. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_launch_trace.py +0 -0
  90. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_loading_server.py +0 -0
  91. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_mode_consistency.py +0 -0
  92. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_nifti_meta.py +0 -0
  93. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_preferences_ui.py +0 -0
  94. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_public_invocation_regressions.py +0 -0
  95. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_render_failure_reporting.py +0 -0
  96. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_render_progress.py +0 -0
  97. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_rgb_pixel_art.py +0 -0
  98. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_safe_server_stop.py +0 -0
  99. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_server_identity_status.py +0 -0
  100. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_session_spec.py +0 -0
  101. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_spawn_ownership.py +0 -0
  102. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_torch.py +0 -0
  103. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_tutorial.py +0 -0
  104. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_tutorial_browser.py +0 -0
  105. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_value_formatting.py +0 -0
  106. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_view_handle_lifecycle.py +0 -0
  107. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_view_launch_execution.py +0 -0
  108. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_vscode_ack_protocol.py +0 -0
  109. {arrayview-0.42.2 → arrayview-0.43.1}/tests/test_window_level_preview.py +0 -0
  110. {arrayview-0.42.2 → arrayview-0.43.1}/tests/ui_audit.py +0 -0
  111. {arrayview-0.42.2 → arrayview-0.43.1}/tests/v_anim_frames/.gitkeep +0 -0
  112. {arrayview-0.42.2 → arrayview-0.43.1}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.42.2
3
+ Version: 0.43.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.42.2"
7
+ version = "0.43.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"
@@ -75,11 +75,29 @@ _DIAGNOSES: list[tuple[str, str, str, str]] = [
75
75
  "fine.",
76
76
  _RETRY),
77
77
 
78
+ # ── A window that is alive but not answering ──────────────────────────
79
+ # Its process exists, so nothing treats it as dead and no other window may
80
+ # take the request from it — targeted requests are invisible to everyone
81
+ # else. The user sees nothing happen at all, and reloading that one window
82
+ # is the only recovery, so say so rather than suggesting a plain retry:
83
+ # retrying sends the next launch to the same stuck window.
84
+ (r"integrated browser open timeout|command discovery timeout|"
85
+ r"browser command did not return", ACTION,
86
+ "The VS Code window handling this request has stopped responding. It is "
87
+ "still running, so nothing else will take the request from it.",
88
+ _RELOAD + " Retrying without reloading sends the next launch to the "
89
+ "same window."),
90
+ (r"claimed but never|no progress after being claimed", ACTION,
91
+ "A VS Code window took this request and then went quiet, so the launch is "
92
+ "stuck with it.",
93
+ _RELOAD),
94
+
78
95
  # ── Timing — slow, not broken ─────────────────────────────────────────
79
96
  (r"did not start the viewer script", ACTION,
80
97
  "VS Code opened an integrated-browser tab, but the tab did not navigate "
81
98
  "to the ArrayView page.",
82
- _RETRY + " If it repeats, reload the VS Code window."),
99
+ _RELOAD + " Retrying without reloading sends the next launch to the "
100
+ "same stuck browser."),
83
101
  (r"did not render a frame|did not become ready",
84
102
  ACTION,
85
103
  "The viewer opened but showed no frame before the deadline — usually a "
@@ -131,7 +149,7 @@ _DIAGNOSES: list[tuple[str, str, str, str]] = [
131
149
  "Check the path — and that the share it lives on is mounted."),
132
150
  (r"not a directory", FATAL,
133
151
  "That path is not a directory.",
134
- "Point --stack at a directory of arrays."),
152
+ "Point --match at a directory of arrays."),
135
153
  (r"invalid vector ?field", FATAL,
136
154
  "The vector field file could not be used with this array.",
137
155
  "It needs the array's spatial shape plus a trailing components axis."),
@@ -0,0 +1,181 @@
1
+ """Extra listening ports on the already-running server.
2
+
3
+ Why this exists
4
+ ---------------
5
+
6
+ In VS Code the viewer is fetched over a forwarded port. Measured 2026-08-06 on
7
+ the real host: a forward that VS Code has *just created* never loses a page
8
+ request (4/4, and 3/3 on a genuine cold start), while one that already exists
9
+ and has sat idle for tens of seconds drops the first two to four. A dropped
10
+ request never arrives — the opener can only discard the tab and open another,
11
+ which the user sees as the viewer flickering before the array appears.
12
+
13
+ While any viewer is open the server keeps the forward from going idle (see the
14
+ nudge in ``_routes_websocket``). The case that leaves is the cold one: no
15
+ viewer is open, so nothing has kept the forward alive, and the first launch pays.
16
+
17
+ A port that has never been forwarded does not have that problem. So a cold
18
+ launch is served from a fresh port, which VS Code forwards for the first time.
19
+ This module adds that port to the process that is *already* running, rather than
20
+ starting another server: same process, same loaded arrays, nothing extra to shut
21
+ down, and repeat opens keep using the original port.
22
+
23
+ Lifetime
24
+ --------
25
+
26
+ An extra port is closed as soon as no viewer is connected **on that port**.
27
+ Viewers report the port they arrived on, so a port is released the moment its
28
+ own viewer goes away, rather than waiting for every viewer everywhere to close —
29
+ otherwise one cold start early in a session would leave a port listening for the
30
+ rest of the day.
31
+
32
+ It must not be closed while a viewer loaded from it is alive: the viewer keeps
33
+ using HTTP on its own origin long after the page has rendered, so pulling the
34
+ port out from under it would break the viewer rather than tidy up after it.
35
+
36
+ A port that never receives a viewer at all — the launch failed, or the user
37
+ closed the tab before it loaded — is closed after ``UNUSED_GRACE_S``. Without
38
+ that, a failed cold start would leak a port with nothing to trigger its release.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import asyncio
44
+ import socket
45
+ import time
46
+
47
+ import arrayview._session as _session_mod
48
+ from arrayview._session import _vprint
49
+
50
+ # Cold starts are rare — one per session in practice, because the first launch
51
+ # leaves a viewer open and every launch after that is warm. The cap is a guard
52
+ # against a pathological caller, not an expected working set.
53
+ MAX_EXTRA_PORTS = 3
54
+
55
+ # How long a freshly opened port may sit with no viewer on it before it is
56
+ # reclaimed. This is the leak guard for a launch that never produced a viewer;
57
+ # it has to outlast a slow array load, because the port is opened before the
58
+ # page is even requested and a large file can take a while to reach its socket.
59
+ UNUSED_GRACE_S = 180.0
60
+
61
+ # port -> {"socket", "server", "task", "viewers", "opened_at"}
62
+ _EXTRA_PORTS: dict[int, dict] = {}
63
+
64
+
65
+ def extra_ports() -> list[int]:
66
+ """Ports currently being served in addition to the main one."""
67
+ return sorted(_EXTRA_PORTS)
68
+
69
+
70
+ def _bind_free_port() -> socket.socket:
71
+ """Bind a port the OS says is free, so nothing has to be guessed."""
72
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
73
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
74
+ sock.bind(("localhost", 0))
75
+ sock.listen(128)
76
+ return sock
77
+
78
+
79
+ async def open_extra_port() -> int | None:
80
+ """Serve the running app on one more, never-before-used port.
81
+
82
+ Returns the port, or ``None`` if one could not be added. Never raises: a
83
+ launch that cannot get a fresh port is still a working launch on the main
84
+ port, only with the cold-start flicker it would have had anyway.
85
+ """
86
+ if len(_EXTRA_PORTS) >= MAX_EXTRA_PORTS:
87
+ _vprint(
88
+ f"[ArrayView] not adding another port; {len(_EXTRA_PORTS)} already open"
89
+ )
90
+ return None
91
+ try:
92
+ import uvicorn
93
+
94
+ from arrayview import _server as _server_mod
95
+
96
+ sock = _bind_free_port()
97
+ port = sock.getsockname()[1]
98
+ config = uvicorn.Config(
99
+ _server_mod.app,
100
+ log_level="error",
101
+ timeout_keep_alive=30,
102
+ ws_ping_interval=None,
103
+ ws="websockets",
104
+ ws_per_message_deflate=True,
105
+ )
106
+ server = uvicorn.Server(config)
107
+ task = asyncio.create_task(server.serve(sockets=[sock]))
108
+ _EXTRA_PORTS[port] = {
109
+ "socket": sock,
110
+ "server": server,
111
+ "task": task,
112
+ "viewers": 0,
113
+ "opened_at": time.monotonic(),
114
+ }
115
+ asyncio.create_task(_close_if_unused(port))
116
+ # The socket is already listening and accepting before serve() runs, so
117
+ # a client that connects immediately is queued rather than refused.
118
+ _vprint(f"[ArrayView] serving a cold-start port on {port}")
119
+ return port
120
+ except Exception as exc: # pragma: no cover - defensive
121
+ _vprint(f"[ArrayView] could not add a cold-start port: {exc}")
122
+ return None
123
+
124
+
125
+ def viewer_connected(port: int | None) -> None:
126
+ """Note that a viewer arrived on *port*, if it is one of ours."""
127
+ entry = _EXTRA_PORTS.get(port)
128
+ if entry is not None:
129
+ entry["viewers"] += 1
130
+
131
+
132
+ async def viewer_disconnected(port: int | None) -> None:
133
+ """Note a viewer left *port*, and release the port if it was the last one."""
134
+ entry = _EXTRA_PORTS.get(port)
135
+ if entry is None:
136
+ return
137
+ entry["viewers"] = max(0, entry["viewers"] - 1)
138
+ if entry["viewers"] == 0:
139
+ await _close_port(port)
140
+
141
+
142
+ async def _close_if_unused(port: int) -> None:
143
+ """Reclaim a port that never got a viewer.
144
+
145
+ Its own release is driven by the viewer that loaded from it, so a launch
146
+ that never produced one would otherwise leave the port listening forever.
147
+ """
148
+ await asyncio.sleep(UNUSED_GRACE_S)
149
+ entry = _EXTRA_PORTS.get(port)
150
+ if entry is not None and entry["viewers"] == 0:
151
+ _vprint(f"[ArrayView] cold-start port {port} was never used")
152
+ await _close_port(port)
153
+
154
+
155
+ async def _close_port(port: int) -> None:
156
+ entry = _EXTRA_PORTS.pop(port, None)
157
+ if entry is None:
158
+ return
159
+ try:
160
+ entry["server"].should_exit = True
161
+ await asyncio.wait_for(entry["task"], timeout=5)
162
+ except Exception:
163
+ entry["task"].cancel()
164
+ finally:
165
+ try:
166
+ entry["socket"].close()
167
+ except Exception:
168
+ pass
169
+ _vprint(f"[ArrayView] released cold-start port {port}")
170
+
171
+
172
+ async def close_extra_ports() -> None:
173
+ """Release every extra port. Only safe with no viewer connected anywhere.
174
+
175
+ A backstop for the per-port release above: if a viewer is lost without its
176
+ disconnect being seen, this still reclaims the port once the server is idle.
177
+ """
178
+ if not _EXTRA_PORTS or _session_mod.VIEWER_SOCKETS > 0:
179
+ return
180
+ for port in list(_EXTRA_PORTS):
181
+ await _close_port(port)
@@ -1139,7 +1139,7 @@ def load_dir_collection(
1139
1139
  overlay match.
1140
1140
  """
1141
1141
  if not base_patterns:
1142
- raise ValueError("--stack requires at least one positional image pattern.")
1142
+ raise ValueError("--match requires at least one positional image pattern.")
1143
1143
  overlays = overlays or []
1144
1144
 
1145
1145
  inferred_case_depth = None
@@ -1338,7 +1338,7 @@ def _load_file_series(path, *, load="lazy", stack="auto"):
1338
1338
 
1339
1339
  One file per patient folder → 4D ``(*vol, P)``. When a folder holds
1340
1340
  several arrays side by side there is no per-folder case to key on, so every
1341
- file becomes its own case — the same collection ``--stack`` builds for that
1341
+ file becomes its own case — the same collection ``--match`` builds for that
1342
1342
  directory.
1343
1343
 
1344
1344
  If every file is NIfTI (.nii/.nii.gz), delegates to
@@ -1383,9 +1383,9 @@ def _load_file_series(path, *, load="lazy", stack="auto"):
1383
1383
 
1384
1384
  # A folder holding several arrays side by side is a flat collection: one
1385
1385
  # case per file. This used to raise and tell the user to retype the command
1386
- # with --stack, which made "open this folder" unreachable from any caller
1386
+ # with --match, which made "open this folder" unreachable from any caller
1387
1387
  # that can only hand over a path (VS Code Explorer, drag-and-drop, the
1388
- # /load route). Route it through the same builder --stack uses so both
1388
+ # /load route). Route it through the same builder --match uses so both
1389
1389
  # spellings produce the same session instead of one of them failing.
1390
1390
  if any(len(files) != 1 for files in patients.values()):
1391
1391
  flat = sorted(fpath for files in patients.values() for fpath in files)
@@ -1445,7 +1445,7 @@ def _load_nifti_series(path, *, load="lazy", stack="auto"):
1445
1445
  raise ValueError(
1446
1446
  f"Patient folder {pdir!r} contains {len(nii_files)} NIfTI "
1447
1447
  f"files: {[os.path.basename(f) for f in nii_files]}. "
1448
- "Use --stack with explicit file patterns for multi-file folders."
1448
+ "Use --match with explicit file patterns for multi-file folders."
1449
1449
  )
1450
1450
 
1451
1451
  return _series_from_file_matrix(file_matrix, load=load, stack=stack)
@@ -2314,6 +2314,16 @@ def _handle_cli_spawned_daemon(
2314
2314
  expected_server_id=daemon_server_id,
2315
2315
  ):
2316
2316
  raise RuntimeError("ArrayView could not confirm no-display session cleanup")
2317
+ # Say what happened. This mode loads the array, proves it registers,
2318
+ # and then deliberately releases it so nothing is left holding the
2319
+ # source — which is the whole point of it, and is why no server
2320
+ # survives and no window opens. Printing nothing at all made a
2321
+ # successful check indistinguishable from silently doing nothing.
2322
+ print(
2323
+ f"[ArrayView] {name}: loaded and released. "
2324
+ f"No display was opened and no server is left running.",
2325
+ flush=True,
2326
+ )
2317
2327
  except Exception:
2318
2328
  _terminate_owned_process(daemon_proc)
2319
2329
  _cleanup_source_staging_dirs(source_staging_dirs)
@@ -5079,7 +5089,7 @@ class _CliCollectionScanProgress:
5079
5089
 
5080
5090
 
5081
5091
  def _print_dir_collection_summary(summary):
5082
- print("[ArrayView] --stack matched collection:")
5092
+ print("[ArrayView] --match matched collection:")
5083
5093
  print(f" cases: {len(summary['cases'])}")
5084
5094
  print(f" spatial shape: {summary['spatial_shape']}")
5085
5095
  print(f" image shape: {summary['shape']}")
@@ -5396,7 +5406,7 @@ def arrayview():
5396
5406
  default=None,
5397
5407
  help=(
5398
5408
  "Segmentation mask overlay. In file mode, pass a concrete mask file. "
5399
- "In --stack mode, pass NAME=PATTERN to add a named overlay role. "
5409
+ "In --match mode, pass NAME=PATTERN to add a named overlay role. "
5400
5410
  "Repeat --overlay to load multiple overlays."
5401
5411
  ),
5402
5412
  )
@@ -5406,7 +5416,7 @@ def arrayview():
5406
5416
  action="append",
5407
5417
  default=None,
5408
5418
  help=(
5409
- "In --stack mode, discover mask filenames below matching per-case "
5419
+ "In --match mode, discover mask filenames below matching per-case "
5410
5420
  "directories as sparse overlay roles. Missing masks render as empty. "
5411
5421
  "Case directories are inferred automatically; --case-regex can "
5412
5422
  "override unusual layouts. Repeat to include multiple directories."
@@ -5493,7 +5503,7 @@ def arrayview():
5493
5503
  help="Display name for the array",
5494
5504
  )
5495
5505
  parser.add_argument(
5496
- "--stack",
5506
+ "--match",
5497
5507
  action="store_true",
5498
5508
  dest="stack_mode",
5499
5509
  help=(
@@ -5502,6 +5512,16 @@ def arrayview():
5502
5512
  "--overlay values become overlay role patterns."
5503
5513
  ),
5504
5514
  )
5515
+ # --stack was renamed to --match. Without this it would not error: argparse
5516
+ # matches unambiguous prefixes, and --stack is a prefix of --stack-policy,
5517
+ # which takes an optional value — so an old command would run and quietly
5518
+ # ignore what the user asked for. Failing loudly is the point of it.
5519
+ parser.add_argument(
5520
+ "--stack",
5521
+ action="store_true",
5522
+ dest="stack_flag_removed",
5523
+ help=argparse.SUPPRESS,
5524
+ )
5505
5525
  parser.add_argument(
5506
5526
  "--series",
5507
5527
  default=None,
@@ -5515,7 +5535,7 @@ def arrayview():
5515
5535
  default=None,
5516
5536
  dest="case_regex",
5517
5537
  help=(
5518
- "Regex used in --stack mode to extract a case id. Must define "
5538
+ "Regex used in --match mode to extract a case id. Must define "
5519
5539
  "a named (?P<case>...) group."
5520
5540
  ),
5521
5541
  )
@@ -5523,7 +5543,7 @@ def arrayview():
5523
5543
  "--dry-run",
5524
5544
  action="store_true",
5525
5545
  dest="dry_run",
5526
- help="In --stack mode, print matched cases and roles without opening a viewer.",
5546
+ help="In --match mode, print matched cases and roles without opening a viewer.",
5527
5547
  )
5528
5548
  parser.add_argument(
5529
5549
  "--stack-policy",
@@ -5532,7 +5552,7 @@ def arrayview():
5532
5552
  choices=("auto", "dense", "ragged"),
5533
5553
  dest="stack_policy",
5534
5554
  help=(
5535
- "Collection stacking policy for --stack mode: auto picks dense when "
5555
+ "Collection stacking policy for --match mode: auto picks dense when "
5536
5556
  "shapes agree and ragged otherwise; dense requires matching shapes; "
5537
5557
  "ragged always keeps per-item shapes."
5538
5558
  ),
@@ -5643,19 +5663,25 @@ def arrayview():
5643
5663
  f"--dims {args.dims!r} is invalid. "
5644
5664
  "Use e.g. 'x,y,:,:' or ':,:,x,y' or '0,1'."
5645
5665
  )
5666
+ if getattr(args, "stack_flag_removed", False):
5667
+ parser.error(
5668
+ "--stack has been renamed to --match, because it never controlled "
5669
+ "stacking: a directory of arrays is stacked either way. It selects "
5670
+ "which files form the collection."
5671
+ )
5646
5672
  if args.dry_run and not args.stack_mode:
5647
- parser.error("--dry-run requires --stack.")
5673
+ parser.error("--dry-run requires --match.")
5648
5674
  excluded_cases = set()
5649
5675
  if args.stack_mode:
5650
5676
  if not args.files:
5651
- parser.error("--stack requires at least one image pattern.")
5677
+ parser.error("--match requires at least one image pattern.")
5652
5678
  if args.compare or args.vectorfield or args.watch or args.rgb:
5653
5679
  parser.error(
5654
- "--stack is incompatible with --compare, --vectorfield, "
5680
+ "--match is incompatible with --compare, --vectorfield, "
5655
5681
  "--watch, and --rgb."
5656
5682
  )
5657
5683
  if args.relay:
5658
- parser.error("--stack is incompatible with --relay.")
5684
+ parser.error("--match is incompatible with --relay.")
5659
5685
  if args.stack_policy and not args.stack_mode:
5660
5686
  if len(args.files) != 1:
5661
5687
  parser.error(
@@ -5866,7 +5892,7 @@ def arrayview():
5866
5892
  if mount is not None or nested_mount is not None:
5867
5893
  _print_failure(
5868
5894
  "network directory collections are not accessed directly. "
5869
- "Copy the collection to local storage before using --stack."
5895
+ "Copy the collection to local storage before using --match."
5870
5896
  )
5871
5897
  sys.exit(1)
5872
5898
  if len(dir_patterns) == 1 and os.path.isdir(dir_patterns[0]):
@@ -5906,11 +5932,11 @@ def arrayview():
5906
5932
  break
5907
5933
  except MissingOverlayCasesError as e:
5908
5934
  if not _confirm_partial_overlay_match(e):
5909
- _print_failure(f"--stack could not match collection: {e}")
5935
+ _print_failure(f"--match could not match collection: {e}")
5910
5936
  sys.exit(1)
5911
5937
  excluded_cases.update(e.missing_cases)
5912
5938
  except Exception as e:
5913
- _print_failure(f"--stack could not match collection: {e}")
5939
+ _print_failure(f"--match could not match collection: {e}")
5914
5940
  sys.exit(1)
5915
5941
  _print_dir_collection_summary(summary)
5916
5942
  if args.dry_run:
@@ -5919,7 +5945,7 @@ def arrayview():
5919
5945
  from arrayview._source_safety import lexical_abspath
5920
5946
 
5921
5947
  if args.overlay_dir:
5922
- parser.error("--overlay-dir requires --stack.")
5948
+ parser.error("--overlay-dir requires --match.")
5923
5949
  try:
5924
5950
  file_overlay_specs = _normalize_file_overlay_specs(args.overlay)
5925
5951
  except ValueError as e:
@@ -137,32 +137,33 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
137
137
  staging_dir = validated_staging_directory(abs_path, claimed_staging)
138
138
  if claimed_staging and staging_dir is None:
139
139
  raise HTTPException(status_code=400, detail="invalid source staging directory")
140
- unsafe_mount = direct_network_mount(abs_path)
141
- if unsafe_mount is not None:
142
- return {
143
- "error": (
144
- f"Direct server access to network mount {unsafe_mount.mountpoint!r} "
145
- "is refused. Launch the file through the arrayview CLI so it can "
146
- "be staged safely."
147
- )
148
- }
149
- nested_source_mount = network_mount_below(abs_path)
150
- if nested_source_mount is not None:
151
- return {
152
- "error": (
153
- f"Source discovery would enter network mount "
154
- f"{nested_source_mount.mountpoint!r}; copy the collection locally first."
155
- )
156
- }
157
- if source_may_require_sibling_discovery(abs_path):
158
- nested_sibling_mount = network_mount_below(os.path.dirname(abs_path))
159
- if nested_sibling_mount is not None:
140
+ if os.environ.get("ARRAYVIEW_SKIP_SOURCE_STAGING", "1") == "0":
141
+ unsafe_mount = direct_network_mount(abs_path)
142
+ if unsafe_mount is not None:
160
143
  return {
161
144
  "error": (
162
- f"Related-file discovery would enter network mount "
163
- f"{nested_sibling_mount.mountpoint!r}; copy the series locally first."
145
+ f"Direct server access to network mount {unsafe_mount.mountpoint!r} "
146
+ "is refused. Launch the file through the arrayview CLI so it can "
147
+ "be staged safely."
164
148
  )
165
149
  }
150
+ nested_source_mount = network_mount_below(abs_path)
151
+ if nested_source_mount is not None:
152
+ return {
153
+ "error": (
154
+ f"Source discovery would enter network mount "
155
+ f"{nested_source_mount.mountpoint!r}; copy the collection locally first."
156
+ )
157
+ }
158
+ if source_may_require_sibling_discovery(abs_path):
159
+ nested_sibling_mount = network_mount_below(os.path.dirname(abs_path))
160
+ if nested_sibling_mount is not None:
161
+ return {
162
+ "error": (
163
+ f"Related-file discovery would enter network mount "
164
+ f"{nested_sibling_mount.mountpoint!r}; copy the series locally first."
165
+ )
166
+ }
166
167
  for pattern in dir_patterns or []:
167
168
  pattern_path = lexical_abspath(str(pattern))
168
169
  scan_root = scan_root_before_magic(pattern_path)
@@ -23,7 +23,7 @@ from arrayview._render import (
23
23
  render_rgb_rgba,
24
24
  render_rgba,
25
25
  )
26
- from arrayview._session import HEAVY_OP_LIMIT_BYTES, SESSIONS
26
+ from arrayview._session import HEAVY_OP_LIMIT_BYTES, SESSIONS, _schedule_prefetch
27
27
  from arrayview._synthetic_mri import (
28
28
  _qmri_adjust_vmin_vmax,
29
29
  qmri_display_slice,
@@ -276,6 +276,21 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
276
276
  mosaic_cols,
277
277
  )
278
278
  render_ms = (time.perf_counter() - render_t0) * 1000.0
279
+ # This endpoint is what compare mode polls per scroll step (the live
280
+ # WS path has its own prefetch call). Without this, a second array
281
+ # open puts every pane on the request-then-wait path with nothing
282
+ # warmed ahead, which is a much bigger hit than the extra data alone.
283
+ # No per-request direction field here (stateless HTTP), so infer it
284
+ # from the index this session last rendered, mirroring the WS guard.
285
+ if slice_dim >= 0 and dim_z < 0:
286
+ prev_idx = getattr(session, "_http_prefetch_prev_idx", None)
287
+ direction = 1
288
+ if prev_idx is not None and len(prev_idx) == len(idx_tuple):
289
+ delta = idx_tuple[slice_dim] - prev_idx[slice_dim]
290
+ if delta:
291
+ direction = 1 if delta > 0 else -1
292
+ session._http_prefetch_prev_idx = idx_tuple
293
+ _schedule_prefetch(session, dim_x, dim_y, list(idx_tuple), slice_dim, direction)
279
294
  encode_t0 = time.perf_counter()
280
295
  img = _pil_image().fromarray(rgba[:, :, :3], mode="RGB")
281
296
  buf = io.BytesIO()
@@ -79,26 +79,27 @@ def register_vectorfield_routes(app) -> None:
79
79
  staging_dir = validated_staging_directory(filepath, claimed_staging)
80
80
  if claimed_staging and staging_dir is None:
81
81
  raise HTTPException(status_code=400, detail="invalid source staging directory")
82
- unsafe_mount = direct_network_mount(filepath)
83
- if unsafe_mount is not None:
84
- return {
85
- "error": (
86
- f"Direct server access to network mount {unsafe_mount.mountpoint!r} "
87
- "is refused; launch through the arrayview CLI."
88
- )
89
- }
90
- nested_mount = network_mount_below(filepath)
91
- if source_may_require_sibling_discovery(filepath):
92
- nested_mount = nested_mount or network_mount_below(
93
- os.path.dirname(filepath)
94
- )
95
- if nested_mount is not None:
96
- return {
97
- "error": (
98
- f"Vector-field discovery would enter network mount "
99
- f"{nested_mount.mountpoint!r}; copy it locally first."
82
+ if os.environ.get("ARRAYVIEW_SKIP_SOURCE_STAGING", "1") == "0":
83
+ unsafe_mount = direct_network_mount(filepath)
84
+ if unsafe_mount is not None:
85
+ return {
86
+ "error": (
87
+ f"Direct server access to network mount {unsafe_mount.mountpoint!r} "
88
+ "is refused; launch through the arrayview CLI."
89
+ )
90
+ }
91
+ nested_mount = network_mount_below(filepath)
92
+ if source_may_require_sibling_discovery(filepath):
93
+ nested_mount = nested_mount or network_mount_below(
94
+ os.path.dirname(filepath)
100
95
  )
101
- }
96
+ if nested_mount is not None:
97
+ return {
98
+ "error": (
99
+ f"Vector-field discovery would enter network mount "
100
+ f"{nested_mount.mountpoint!r}; copy it locally first."
101
+ )
102
+ }
102
103
  components_dim = body.get("components_dim")
103
104
  session = SESSIONS.get(sid)
104
105
  if not session: