arrayview 0.42.2__tar.gz → 0.43.0__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.
- {arrayview-0.42.2 → arrayview-0.43.0}/PKG-INFO +1 -1
- {arrayview-0.42.2 → arrayview-0.43.0}/pyproject.toml +1 -1
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_diagnostics.py +18 -1
- arrayview-0.43.0/src/arrayview/_extra_ports.py +181 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_io.py +5 -5
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_launcher.py +41 -15
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_websocket.py +102 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_server.py +22 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_session.py +7 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_viewer.html +808 -34
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_vscode_browser.py +9 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_vscode_extension.py +1 -1
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_vscode_signal.py +49 -0
- arrayview-0.43.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_api.py +3 -3
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_browser.py +125 -8
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_cli.py +10 -10
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_lifecycle_contract.py +45 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_mode_roundtrip.py +13 -6
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_nifti_series.py +3 -3
- arrayview-0.42.2/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/.gitignore +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/LICENSE +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/README.md +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_config.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_diff.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_platform.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_render.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/_watch.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/conftest.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/lifecycle_matrix.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_backend_shared.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_config.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_dicom.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_instance_registry.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_invocation_contract_matrix.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_io.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_launch_diagnostics.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_launch_execution_ownership.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_launch_plan.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_launch_trace.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_loading_server.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_preferences_ui.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_public_invocation_regressions.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_render_failure_reporting.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_render_progress.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_session_spec.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_source_safety.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_torch.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_tutorial.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_tutorial_browser.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_value_formatting.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_vscode_ack_protocol.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/test_window_level_preview.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/ui_audit.py +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.42.2 → arrayview-0.43.0}/tests/visual_smoke.py +0 -0
|
@@ -75,6 +75,23 @@ _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 "
|
|
@@ -131,7 +148,7 @@ _DIAGNOSES: list[tuple[str, str, str, str]] = [
|
|
|
131
148
|
"Check the path — and that the share it lives on is mounted."),
|
|
132
149
|
(r"not a directory", FATAL,
|
|
133
150
|
"That path is not a directory.",
|
|
134
|
-
"Point --
|
|
151
|
+
"Point --match at a directory of arrays."),
|
|
135
152
|
(r"invalid vector ?field", FATAL,
|
|
136
153
|
"The vector field file could not be used with this array.",
|
|
137
154
|
"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("--
|
|
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 ``--
|
|
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 --
|
|
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 --
|
|
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 --
|
|
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] --
|
|
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 --
|
|
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 --
|
|
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
|
-
"--
|
|
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 --
|
|
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 --
|
|
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 --
|
|
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 --
|
|
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("--
|
|
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
|
-
"--
|
|
5680
|
+
"--match is incompatible with --compare, --vectorfield, "
|
|
5655
5681
|
"--watch, and --rgb."
|
|
5656
5682
|
)
|
|
5657
5683
|
if args.relay:
|
|
5658
|
-
parser.error("--
|
|
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 --
|
|
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"--
|
|
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"--
|
|
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 --
|
|
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:
|
|
@@ -147,6 +147,79 @@ async def _await_session_reporting_read_progress(ws, sid):
|
|
|
147
147
|
return _session_mod.SESSIONS.get(sid)
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
# How often to consider nudging, how long the path must have been unused
|
|
151
|
+
# before it is worth nudging, and how many connections the nudge opens.
|
|
152
|
+
_PATH_NUDGE_INTERVAL_S = 15.0
|
|
153
|
+
_PATH_NUDGE_IDLE_S = 20.0
|
|
154
|
+
_PATH_NUDGE_CONNECTIONS = 3
|
|
155
|
+
_PATH_NUDGE_TASK: "asyncio.Task | None" = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _nudge_client_path_forever() -> None:
|
|
159
|
+
"""Keep the client's connection to this server from going idle.
|
|
160
|
+
|
|
161
|
+
In VS Code the viewer is reached over a forwarded port. Measured
|
|
162
|
+
2026-08-06: once that forward has sat unused for tens of seconds, the first
|
|
163
|
+
two or three connections through it are dropped outright — the tab opens,
|
|
164
|
+
nothing is ever requested, and the opener has to discard the tab and open
|
|
165
|
+
another, which the user sees as flickering. Traffic through the forward
|
|
166
|
+
prevents it; an open viewer is the only thing on the client side that can
|
|
167
|
+
produce that traffic on demand.
|
|
168
|
+
|
|
169
|
+
Exactly one viewer is asked, so the cost does not grow with the number of
|
|
170
|
+
open tabs — which matters, because many open viewers already push against a
|
|
171
|
+
per-window connection ceiling, and scaling the nudge with tab count would
|
|
172
|
+
push hardest exactly when there is least headroom. Nothing is sent while
|
|
173
|
+
arrays are being opened: a page served in the last ``_PATH_NUDGE_IDLE_S``
|
|
174
|
+
means the path is already in use.
|
|
175
|
+
"""
|
|
176
|
+
while True:
|
|
177
|
+
try:
|
|
178
|
+
await asyncio.sleep(_PATH_NUDGE_INTERVAL_S)
|
|
179
|
+
sockets = _session_mod.LIVE_VIEWER_SOCKETS
|
|
180
|
+
if not sockets:
|
|
181
|
+
continue
|
|
182
|
+
idle_for = time.time() - _session_mod.LAST_PAGE_SERVED_AT
|
|
183
|
+
if idle_for < _PATH_NUDGE_IDLE_S:
|
|
184
|
+
continue
|
|
185
|
+
# Newest first: the most recently opened viewer is the most likely
|
|
186
|
+
# to still be on screen, and a socket that has died but not yet
|
|
187
|
+
# been cleaned up would otherwise swallow every nudge in silence —
|
|
188
|
+
# which is exactly how this failed on its first measurement. Stop
|
|
189
|
+
# at the first viewer that actually accepts it, so the traffic
|
|
190
|
+
# stays one burst no matter how many viewers are open.
|
|
191
|
+
payload = {
|
|
192
|
+
"type": "warm_path",
|
|
193
|
+
"connections": _PATH_NUDGE_CONNECTIONS,
|
|
194
|
+
}
|
|
195
|
+
for ws in reversed(list(sockets)):
|
|
196
|
+
try:
|
|
197
|
+
await ws.send_json(payload)
|
|
198
|
+
break
|
|
199
|
+
except Exception:
|
|
200
|
+
try:
|
|
201
|
+
sockets.remove(ws)
|
|
202
|
+
except ValueError:
|
|
203
|
+
pass
|
|
204
|
+
except asyncio.CancelledError:
|
|
205
|
+
raise
|
|
206
|
+
except Exception:
|
|
207
|
+
# A nudge is best-effort. It must never take the server down or
|
|
208
|
+
# end the loop, or the next launch silently loses its protection.
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _ensure_path_nudge_task() -> None:
|
|
213
|
+
"""Start the nudge loop once, when the first viewer connects."""
|
|
214
|
+
global _PATH_NUDGE_TASK
|
|
215
|
+
if _PATH_NUDGE_TASK is not None and not _PATH_NUDGE_TASK.done():
|
|
216
|
+
return
|
|
217
|
+
try:
|
|
218
|
+
_PATH_NUDGE_TASK = asyncio.create_task(_nudge_client_path_forever())
|
|
219
|
+
except RuntimeError:
|
|
220
|
+
_PATH_NUDGE_TASK = None
|
|
221
|
+
|
|
222
|
+
|
|
150
223
|
async def _send_json_quietly(ws, payload):
|
|
151
224
|
try:
|
|
152
225
|
await ws.send_json(payload)
|
|
@@ -287,6 +360,18 @@ def register_websocket_routes(app) -> None:
|
|
|
287
360
|
|
|
288
361
|
_session_mod.VIEWER_SOCKETS += 1
|
|
289
362
|
_session_mod.VIEWER_CONNECTIONS_SEEN += 1
|
|
363
|
+
_session_mod.LIVE_VIEWER_SOCKETS.append(ws)
|
|
364
|
+
_ensure_path_nudge_task()
|
|
365
|
+
# Which port this viewer arrived on, so a cold-start port can be
|
|
366
|
+
# released the moment its own viewer goes away rather than waiting for
|
|
367
|
+
# every viewer everywhere to close.
|
|
368
|
+
arrived_on = (ws.scope.get("server") or (None, None))[1]
|
|
369
|
+
try:
|
|
370
|
+
from arrayview import _extra_ports
|
|
371
|
+
|
|
372
|
+
_extra_ports.viewer_connected(arrived_on)
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
290
375
|
_session_mod.VIEWER_SID_COUNTS[sid] = (
|
|
291
376
|
_session_mod.VIEWER_SID_COUNTS.get(sid, 0) + 1
|
|
292
377
|
)
|
|
@@ -672,6 +757,23 @@ def register_websocket_routes(app) -> None:
|
|
|
672
757
|
except asyncio.CancelledError:
|
|
673
758
|
pass
|
|
674
759
|
_session_mod.VIEWER_SOCKETS = max(0, _session_mod.VIEWER_SOCKETS - 1)
|
|
760
|
+
try:
|
|
761
|
+
_session_mod.LIVE_VIEWER_SOCKETS.remove(ws)
|
|
762
|
+
except ValueError:
|
|
763
|
+
pass
|
|
764
|
+
try:
|
|
765
|
+
from arrayview import _extra_ports
|
|
766
|
+
|
|
767
|
+
# Release this viewer's own cold-start port as soon as it is
|
|
768
|
+
# the last one there — not when the whole server goes idle, or
|
|
769
|
+
# a single cold start would keep a port listening all session.
|
|
770
|
+
asyncio.create_task(_extra_ports.viewer_disconnected(arrived_on))
|
|
771
|
+
if _session_mod.VIEWER_SOCKETS == 0:
|
|
772
|
+
# Backstop: if a viewer was lost without its disconnect
|
|
773
|
+
# being seen, an idle server still reclaims everything.
|
|
774
|
+
asyncio.create_task(_extra_ports.close_extra_ports())
|
|
775
|
+
except Exception:
|
|
776
|
+
pass
|
|
675
777
|
sid_count = max(0, _session_mod.VIEWER_SID_COUNTS.get(sid, 0) - 1)
|
|
676
778
|
if sid_count:
|
|
677
779
|
_session_mod.VIEWER_SID_COUNTS[sid] = sid_count
|
|
@@ -292,6 +292,23 @@ def get_shell():
|
|
|
292
292
|
return HTMLResponse(content=_SHELL_HTML)
|
|
293
293
|
|
|
294
294
|
|
|
295
|
+
@app.post("/cold-start-port")
|
|
296
|
+
async def cold_start_port():
|
|
297
|
+
"""Serve this same server on one more, never-before-forwarded port.
|
|
298
|
+
|
|
299
|
+
Asked for only when no viewer is connected. A forward VS Code has just
|
|
300
|
+
created does not drop its first requests, while one that has sat idle does,
|
|
301
|
+
and that is what makes a cold launch flicker. The port is released as soon
|
|
302
|
+
as no viewer is connected anywhere.
|
|
303
|
+
"""
|
|
304
|
+
from arrayview import _extra_ports
|
|
305
|
+
|
|
306
|
+
if _session_mod.VIEWER_SOCKETS > 0:
|
|
307
|
+
return {"port": None, "reason": "a viewer is already connected"}
|
|
308
|
+
port = await _extra_ports.open_extra_port()
|
|
309
|
+
return {"port": port}
|
|
310
|
+
|
|
311
|
+
|
|
295
312
|
@app.get("/ping")
|
|
296
313
|
def ping():
|
|
297
314
|
"""Health marker so clients can verify this is an ArrayView server."""
|
|
@@ -351,7 +368,12 @@ def _trace_page_request(
|
|
|
351
368
|
from the opener's side alone: the relay may have dropped the page request,
|
|
352
369
|
or the page may have arrived and the script stalled. This distinguishes
|
|
353
370
|
the two. Inert unless ``ARRAYVIEW_LAUNCH_TRACE`` is set.
|
|
371
|
+
|
|
372
|
+
Also stamps when a page was last served, which the idle nudge in
|
|
373
|
+
``_routes_websocket`` uses to stay silent while arrays are being opened.
|
|
374
|
+
That stamp is not diagnostic and is kept before the trace guard below.
|
|
354
375
|
"""
|
|
376
|
+
_session_mod.LAST_PAGE_SERVED_AT = time.time()
|
|
355
377
|
if not os.environ.get("ARRAYVIEW_LAUNCH_TRACE"):
|
|
356
378
|
return
|
|
357
379
|
try:
|
|
@@ -33,6 +33,13 @@ VIEWER_SID_COUNTS: dict[str, int] = {} # active viewer WS count per session ID
|
|
|
33
33
|
VIEWER_CONNECTION_EPOCHS: dict[str, int] = {}
|
|
34
34
|
VIEWER_RELEASE_TASKS: dict[str, asyncio.Task] = {}
|
|
35
35
|
VIEWER_CONNECTIONS_SEEN = 0 # monotonic count of accepted viewer WS connections
|
|
36
|
+
# Live viewer WebSockets, oldest first. Only the first one is ever asked to
|
|
37
|
+
# keep the client's connection to this server from going idle, so the nudge
|
|
38
|
+
# traffic stays the same whether the user has one viewer open or twenty.
|
|
39
|
+
LIVE_VIEWER_SOCKETS: list = []
|
|
40
|
+
# When a viewer page was last served. The path only decays while nothing is
|
|
41
|
+
# being opened, so the nudge is skipped entirely while the user is working.
|
|
42
|
+
LAST_PAGE_SERVED_AT: float = 0.0
|
|
36
43
|
SHELL_SOCKETS = [] # webview shell WS connections (for tab injection)
|
|
37
44
|
SHELL_REQUEST_IDS: set[str] = set()
|
|
38
45
|
VIEWER_REQUEST_COUNTS: dict[tuple[str, str], int] = {}
|