arrayview 0.43.0__tar.gz → 0.43.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.
- {arrayview-0.43.0 → arrayview-0.43.2}/PKG-INFO +2 -2
- {arrayview-0.43.0 → arrayview-0.43.2}/pyproject.toml +1 -1
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_diagnostics.py +2 -1
- arrayview-0.43.2/src/arrayview/_extra_ports.py +283 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_loading.py +27 -23
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_rendering.py +16 -1
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_vectorfield.py +20 -19
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_websocket.py +1 -1
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_server.py +44 -9
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_session.py +10 -1
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_source_safety.py +8 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_viewer.html +397 -61
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_vscode_browser.py +2 -2
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_vscode_extension.py +1 -1
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_vscode_signal.py +69 -25
- arrayview-0.43.2/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/lifecycle_matrix.py +1 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_api.py +377 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_launch_diagnostics.py +4 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_mode_matrix.py +75 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_source_safety.py +5 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_vscode_ack_protocol.py +106 -0
- arrayview-0.43.0/src/arrayview/_extra_ports.py +0 -181
- arrayview-0.43.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/.gitignore +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/LICENSE +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/README.md +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/__init__.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/__main__.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_app.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_config.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_diff.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_icon.png +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_io.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_launcher.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_platform.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_render.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_shell.html +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_torch.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/_watch.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/src/arrayview/todo.md +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/capture_v_animation.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/conftest.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_backend_shared.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_browser.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_cli.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_config.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_dicom.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_drop_import.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_instance_registry.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_invocation_contract_matrix.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_io.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_large_arrays.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_launch_execution_ownership.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_launch_plan.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_launch_trace.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_lifecycle_contract.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_loading_server.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_nifti_series.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_preferences_ui.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_public_invocation_regressions.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_render_failure_reporting.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_render_progress.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_session_spec.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_torch.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_tutorial.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_tutorial_browser.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_value_formatting.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/test_window_level_preview.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/ui_audit.py +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.43.0 → arrayview-0.43.2}/tests/visual_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: arrayview
|
|
3
|
-
Version: 0.43.
|
|
3
|
+
Version: 0.43.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
|
|
@@ -96,7 +96,8 @@ _DIAGNOSES: list[tuple[str, str, str, str]] = [
|
|
|
96
96
|
(r"did not start the viewer script", ACTION,
|
|
97
97
|
"VS Code opened an integrated-browser tab, but the tab did not navigate "
|
|
98
98
|
"to the ArrayView page.",
|
|
99
|
-
|
|
99
|
+
_RELOAD + " Retrying without reloading sends the next launch to the "
|
|
100
|
+
"same stuck browser."),
|
|
100
101
|
(r"did not render a frame|did not become ready",
|
|
101
102
|
ACTION,
|
|
102
103
|
"The viewer opened but showed no frame before the deadline — usually a "
|
|
@@ -0,0 +1,283 @@
|
|
|
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 overlapping opens share the same private viewer port.
|
|
22
|
+
|
|
23
|
+
Lifetime
|
|
24
|
+
--------
|
|
25
|
+
|
|
26
|
+
Every launch leases the singleton viewer port before its URL is handed off.
|
|
27
|
+
The port is closed only when no viewer is connected **and** no launch still has
|
|
28
|
+
a live lease. This prevents the last old viewer from closing the port while a
|
|
29
|
+
new viewer is between URL handoff and WebSocket connection.
|
|
30
|
+
|
|
31
|
+
It must not be closed while a viewer loaded from it is alive: the viewer keeps
|
|
32
|
+
using HTTP on its own origin long after the page has rendered, so pulling the
|
|
33
|
+
port out from under it would break the viewer rather than tidy up after it.
|
|
34
|
+
|
|
35
|
+
A launch that never receives a viewer loses its lease after a bounded timeout.
|
|
36
|
+
Without that, a failed handoff would leak a port with nothing to trigger its
|
|
37
|
+
release.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import asyncio
|
|
43
|
+
import socket
|
|
44
|
+
import time
|
|
45
|
+
|
|
46
|
+
import arrayview._session as _session_mod
|
|
47
|
+
from arrayview._session import _vprint
|
|
48
|
+
|
|
49
|
+
# A lease must be long enough for a slow or temporarily wedged VS Code handoff,
|
|
50
|
+
# but a caller may not keep a private port alive without bound.
|
|
51
|
+
MIN_LEASE_TTL_MS = 1
|
|
52
|
+
MAX_LEASE_TTL_MS = 300_000
|
|
53
|
+
# Keep the origin alive across the viewer's ordinary reconnect window. The
|
|
54
|
+
# correlated session uses the same 30-second default before it treats a lost
|
|
55
|
+
# WebSocket as a closed tab; closing the listener sooner would make that
|
|
56
|
+
# recovery impossible even though the server and session are still healthy.
|
|
57
|
+
VIEWER_RECONNECT_GRACE_S = 30.0
|
|
58
|
+
|
|
59
|
+
# port -> {"socket", "server", "task", "viewers", "opened_at", "leases",
|
|
60
|
+
# "idle_until", "idle_generation"}
|
|
61
|
+
_EXTRA_PORTS: dict[int, dict] = {}
|
|
62
|
+
_PORT_LOCK = asyncio.Lock()
|
|
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
|
+
def _start_extra_port() -> tuple[int, dict] | None:
|
|
80
|
+
"""Start the singleton extra listener; caller holds ``_PORT_LOCK``."""
|
|
81
|
+
try:
|
|
82
|
+
import uvicorn
|
|
83
|
+
|
|
84
|
+
from arrayview import _server as _server_mod
|
|
85
|
+
|
|
86
|
+
sock = _bind_free_port()
|
|
87
|
+
port = sock.getsockname()[1]
|
|
88
|
+
config = uvicorn.Config(
|
|
89
|
+
_server_mod.app,
|
|
90
|
+
log_level="error",
|
|
91
|
+
timeout_keep_alive=30,
|
|
92
|
+
ws_ping_interval=None,
|
|
93
|
+
ws="websockets",
|
|
94
|
+
ws_per_message_deflate=True,
|
|
95
|
+
)
|
|
96
|
+
server = uvicorn.Server(config)
|
|
97
|
+
task = asyncio.create_task(server.serve(sockets=[sock]))
|
|
98
|
+
entry = {
|
|
99
|
+
"socket": sock,
|
|
100
|
+
"server": server,
|
|
101
|
+
"task": task,
|
|
102
|
+
"viewers": 0,
|
|
103
|
+
"opened_at": time.monotonic(),
|
|
104
|
+
"leases": {},
|
|
105
|
+
"idle_until": 0.0,
|
|
106
|
+
"idle_generation": 0,
|
|
107
|
+
}
|
|
108
|
+
# The socket is already listening and accepting before serve() runs, so
|
|
109
|
+
# a client that connects immediately is queued rather than refused.
|
|
110
|
+
_vprint(f"[ArrayView] serving the private viewer port on {port}")
|
|
111
|
+
return port, entry
|
|
112
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
113
|
+
_vprint(f"[ArrayView] could not add the private viewer port: {exc}")
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def acquire_viewer_port(request_id: str, ttl_ms: int) -> tuple[int | None, bool]:
|
|
118
|
+
"""Lease the singleton private viewer port for one launch.
|
|
119
|
+
|
|
120
|
+
The selection and lease insertion are one event-loop transaction, so two
|
|
121
|
+
launches arriving before either viewer connects cannot create two ports.
|
|
122
|
+
"""
|
|
123
|
+
ttl_ms = max(MIN_LEASE_TTL_MS, min(MAX_LEASE_TTL_MS, int(ttl_ms)))
|
|
124
|
+
retired: list[tuple[int, dict]] = []
|
|
125
|
+
async with _PORT_LOCK:
|
|
126
|
+
now = time.monotonic()
|
|
127
|
+
for port, entry in list(_EXTRA_PORTS.items()):
|
|
128
|
+
leases = entry.setdefault("leases", {})
|
|
129
|
+
for lease_id, expires_at in list(leases.items()):
|
|
130
|
+
if expires_at <= now:
|
|
131
|
+
leases.pop(lease_id, None)
|
|
132
|
+
if (
|
|
133
|
+
entry["viewers"] == 0
|
|
134
|
+
and not leases
|
|
135
|
+
and float(entry.get("idle_until", 0.0)) <= now
|
|
136
|
+
):
|
|
137
|
+
_EXTRA_PORTS.pop(port, None)
|
|
138
|
+
retired.append((port, entry))
|
|
139
|
+
reused = bool(_EXTRA_PORTS)
|
|
140
|
+
if reused:
|
|
141
|
+
port = next(iter(_EXTRA_PORTS))
|
|
142
|
+
entry = _EXTRA_PORTS[port]
|
|
143
|
+
else:
|
|
144
|
+
started = _start_extra_port()
|
|
145
|
+
if started is None:
|
|
146
|
+
for retired_port, retired_entry in retired:
|
|
147
|
+
asyncio.create_task(
|
|
148
|
+
_shutdown_entry(retired_port, retired_entry)
|
|
149
|
+
)
|
|
150
|
+
return None, False
|
|
151
|
+
port, entry = started
|
|
152
|
+
_EXTRA_PORTS[port] = entry
|
|
153
|
+
expires_at = now + ttl_ms / 1000.0
|
|
154
|
+
entry.setdefault("leases", {})[request_id] = expires_at
|
|
155
|
+
entry["idle_until"] = 0.0
|
|
156
|
+
entry["idle_generation"] = int(entry.get("idle_generation", 0)) + 1
|
|
157
|
+
asyncio.create_task(_expire_lease(port, request_id, expires_at))
|
|
158
|
+
for retired_port, retired_entry in retired:
|
|
159
|
+
asyncio.create_task(_shutdown_entry(retired_port, retired_entry))
|
|
160
|
+
return port, reused
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def viewer_connected(port: int | None, request_id: str | None = None) -> None:
|
|
164
|
+
"""Note that a viewer arrived and consume only its correlated lease."""
|
|
165
|
+
entry = _EXTRA_PORTS.get(port)
|
|
166
|
+
if entry is None:
|
|
167
|
+
return
|
|
168
|
+
leases = entry.setdefault("leases", {})
|
|
169
|
+
if request_id:
|
|
170
|
+
consumed = leases.pop(request_id, None)
|
|
171
|
+
# Opener 0.15.47 asks for the port before it knows how to lease it.
|
|
172
|
+
# The endpoint gives that request a bounded compatibility lease so a
|
|
173
|
+
# package update does not force an immediate window reload. Consume one
|
|
174
|
+
# such lease only when no exact lease exists.
|
|
175
|
+
if consumed is None:
|
|
176
|
+
compatibility_lease = next(
|
|
177
|
+
(
|
|
178
|
+
lease_id
|
|
179
|
+
for lease_id in leases
|
|
180
|
+
if lease_id.startswith("compat-")
|
|
181
|
+
),
|
|
182
|
+
None,
|
|
183
|
+
)
|
|
184
|
+
if compatibility_lease is not None:
|
|
185
|
+
leases.pop(compatibility_lease, None)
|
|
186
|
+
entry["viewers"] += 1
|
|
187
|
+
entry["idle_until"] = 0.0
|
|
188
|
+
entry["idle_generation"] = int(entry.get("idle_generation", 0)) + 1
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def viewer_disconnected(port: int | None) -> None:
|
|
192
|
+
"""Release a port only after its last viewer and last handoff lease."""
|
|
193
|
+
close_after: tuple[int, float] | None = None
|
|
194
|
+
async with _PORT_LOCK:
|
|
195
|
+
entry = _EXTRA_PORTS.get(port)
|
|
196
|
+
if entry is None:
|
|
197
|
+
return
|
|
198
|
+
entry["viewers"] = max(0, entry["viewers"] - 1)
|
|
199
|
+
if entry["viewers"] == 0:
|
|
200
|
+
entry["idle_generation"] = int(entry.get("idle_generation", 0)) + 1
|
|
201
|
+
generation = entry["idle_generation"]
|
|
202
|
+
idle_until = time.monotonic() + VIEWER_RECONNECT_GRACE_S
|
|
203
|
+
entry["idle_until"] = idle_until
|
|
204
|
+
close_after = (generation, idle_until)
|
|
205
|
+
if close_after is not None:
|
|
206
|
+
generation, idle_until = close_after
|
|
207
|
+
asyncio.create_task(_close_after_reconnect_grace(port, generation, idle_until))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _close_after_reconnect_grace(
|
|
211
|
+
port: int, generation: int, idle_until: float
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Close only if no reconnect or new launch superseded this disconnect."""
|
|
214
|
+
await asyncio.sleep(max(0.0, idle_until - time.monotonic()))
|
|
215
|
+
async with _PORT_LOCK:
|
|
216
|
+
entry = _EXTRA_PORTS.get(port)
|
|
217
|
+
if (
|
|
218
|
+
entry is None
|
|
219
|
+
or int(entry.get("idle_generation", 0)) != generation
|
|
220
|
+
or entry["viewers"] > 0
|
|
221
|
+
or entry.get("leases")
|
|
222
|
+
):
|
|
223
|
+
return
|
|
224
|
+
await _close_port(port, only_if_idle=True)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def _expire_lease(port: int, request_id: str, expires_at: float) -> None:
|
|
228
|
+
"""Expire one exact launch lease and reclaim an otherwise idle port."""
|
|
229
|
+
await asyncio.sleep(max(0.0, expires_at - time.monotonic()))
|
|
230
|
+
should_close = False
|
|
231
|
+
async with _PORT_LOCK:
|
|
232
|
+
entry = _EXTRA_PORTS.get(port)
|
|
233
|
+
if entry is None:
|
|
234
|
+
return
|
|
235
|
+
leases = entry.setdefault("leases", {})
|
|
236
|
+
if leases.get(request_id) != expires_at:
|
|
237
|
+
return
|
|
238
|
+
leases.pop(request_id, None)
|
|
239
|
+
should_close = entry["viewers"] == 0 and not leases
|
|
240
|
+
if should_close:
|
|
241
|
+
_vprint(f"[ArrayView] viewer-port lease expired for {request_id[:12]}")
|
|
242
|
+
await _close_port(port, only_if_idle=True)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def _close_port(port: int, *, only_if_idle: bool = False) -> None:
|
|
246
|
+
async with _PORT_LOCK:
|
|
247
|
+
entry = _EXTRA_PORTS.get(port)
|
|
248
|
+
if entry is None:
|
|
249
|
+
return
|
|
250
|
+
if only_if_idle:
|
|
251
|
+
if entry["viewers"] > 0 or entry.get("leases"):
|
|
252
|
+
return
|
|
253
|
+
if float(entry.get("idle_until", 0.0)) > time.monotonic():
|
|
254
|
+
return
|
|
255
|
+
_EXTRA_PORTS.pop(port, None)
|
|
256
|
+
await _shutdown_entry(port, entry)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def _shutdown_entry(port: int, entry: dict) -> None:
|
|
260
|
+
"""Stop one listener already removed from the selectable port registry."""
|
|
261
|
+
try:
|
|
262
|
+
entry["server"].should_exit = True
|
|
263
|
+
await asyncio.wait_for(entry["task"], timeout=5)
|
|
264
|
+
except Exception:
|
|
265
|
+
entry["task"].cancel()
|
|
266
|
+
finally:
|
|
267
|
+
try:
|
|
268
|
+
entry["socket"].close()
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
_vprint(f"[ArrayView] released private viewer port {port}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def close_extra_ports() -> None:
|
|
275
|
+
"""Release every extra port. Only safe with no viewer connected anywhere.
|
|
276
|
+
|
|
277
|
+
A backstop for the per-port release above: if a viewer is lost without its
|
|
278
|
+
disconnect being seen, this still reclaims the port once the server is idle.
|
|
279
|
+
"""
|
|
280
|
+
if not _EXTRA_PORTS or _session_mod.VIEWER_SOCKETS > 0:
|
|
281
|
+
return
|
|
282
|
+
for port in list(_EXTRA_PORTS):
|
|
283
|
+
await _close_port(port, only_if_idle=True)
|
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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:
|
|
143
|
+
return {
|
|
144
|
+
"error": (
|
|
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."
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
nested_source_mount = network_mount_below(abs_path)
|
|
151
|
+
if nested_source_mount is not None:
|
|
160
152
|
return {
|
|
161
153
|
"error": (
|
|
162
|
-
f"
|
|
163
|
-
f"{
|
|
154
|
+
f"Source discovery would enter network mount "
|
|
155
|
+
f"{nested_source_mount.mountpoint!r}; copy the collection locally first."
|
|
164
156
|
)
|
|
165
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)
|
|
@@ -831,7 +832,10 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
|
|
|
831
832
|
)
|
|
832
833
|
if require_display_ack:
|
|
833
834
|
if not signal_request:
|
|
834
|
-
raise RuntimeError(
|
|
835
|
+
raise RuntimeError(
|
|
836
|
+
signal_request.failure_reason
|
|
837
|
+
or "VS Code display request could not be written"
|
|
838
|
+
)
|
|
835
839
|
ack = await asyncio.to_thread(
|
|
836
840
|
_wait_for_vscode_ack,
|
|
837
841
|
signal_request,
|
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
@@ -369,7 +369,7 @@ def register_websocket_routes(app) -> None:
|
|
|
369
369
|
try:
|
|
370
370
|
from arrayview import _extra_ports
|
|
371
371
|
|
|
372
|
-
_extra_ports.viewer_connected(arrived_on)
|
|
372
|
+
_extra_ports.viewer_connected(arrived_on, launch_request_id)
|
|
373
373
|
except Exception:
|
|
374
374
|
pass
|
|
375
375
|
_session_mod.VIEWER_SID_COUNTS[sid] = (
|
|
@@ -293,20 +293,55 @@ def get_shell():
|
|
|
293
293
|
|
|
294
294
|
|
|
295
295
|
@app.post("/cold-start-port")
|
|
296
|
-
async def cold_start_port():
|
|
296
|
+
async def cold_start_port(payload: dict):
|
|
297
297
|
"""Serve this same server on one more, never-before-forwarded port.
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
299
|
+
A forward VS Code has just created does not drop its first requests, while
|
|
300
|
+
one that has sat idle for tens of seconds drops the first two to four. So
|
|
301
|
+
the viewer page is served from an extra port rather than the main one: a
|
|
302
|
+
fresh port for the first launch, then that same port is leased to later
|
|
303
|
+
launches. A lease keeps the route alive between handoff and connection,
|
|
304
|
+
including when the previous viewer closes during that gap. The port is
|
|
305
|
+
released only after its last viewer and its last bounded lease are gone.
|
|
303
306
|
"""
|
|
304
307
|
from arrayview import _extra_ports
|
|
305
308
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
request_id = payload.get("requestId")
|
|
310
|
+
ttl_ms = payload.get("ttlMs")
|
|
311
|
+
expected_server_id = payload.get("expectedServerId")
|
|
312
|
+
compatibility_request = not payload
|
|
313
|
+
if compatibility_request:
|
|
314
|
+
# Opener 0.15.47 shipped before correlated leases. Keep that already-
|
|
315
|
+
# loaded host working after the Python package updates, so users are
|
|
316
|
+
# not forced to reload a VS Code window merely to open the next array.
|
|
317
|
+
request_id = f"compat-{uuid.uuid4().hex}"
|
|
318
|
+
ttl_ms = 240_000
|
|
319
|
+
expected_server_id = _session_mod.SERVER_RUNTIME.instance_id
|
|
320
|
+
if not isinstance(request_id, str) or not request_id.strip():
|
|
321
|
+
raise HTTPException(status_code=400, detail="requestId is required")
|
|
322
|
+
if isinstance(ttl_ms, bool) or not isinstance(ttl_ms, int) or ttl_ms <= 0:
|
|
323
|
+
raise HTTPException(status_code=400, detail="ttlMs must be a positive integer")
|
|
324
|
+
if not isinstance(expected_server_id, str) or not expected_server_id:
|
|
325
|
+
raise HTTPException(status_code=400, detail="expectedServerId is required")
|
|
326
|
+
actual_server_id = _session_mod.SERVER_RUNTIME.instance_id
|
|
327
|
+
if expected_server_id != actual_server_id:
|
|
328
|
+
raise HTTPException(
|
|
329
|
+
status_code=409,
|
|
330
|
+
detail="The requested ArrayView backend no longer owns this port",
|
|
331
|
+
)
|
|
332
|
+
port, reused = await _extra_ports.acquire_viewer_port(request_id, ttl_ms)
|
|
333
|
+
if port is None:
|
|
334
|
+
raise HTTPException(
|
|
335
|
+
status_code=503,
|
|
336
|
+
detail="Could not prepare the private VS Code viewer connection",
|
|
337
|
+
)
|
|
338
|
+
return {
|
|
339
|
+
"port": port,
|
|
340
|
+
"reused": reused,
|
|
341
|
+
"ttlMs": min(ttl_ms, _extra_ports.MAX_LEASE_TTL_MS),
|
|
342
|
+
"compatibility": compatibility_request,
|
|
343
|
+
"requestId": request_id,
|
|
344
|
+
}
|
|
310
345
|
|
|
311
346
|
|
|
312
347
|
@app.get("/ping")
|
|
@@ -141,14 +141,22 @@ _OVERLAY_PREFETCH_POOL = None
|
|
|
141
141
|
_PREFETCH_LOCK = threading.Lock()
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
_PREFETCH_WORKERS: int = max(2, min(4, os.cpu_count() or 2))
|
|
145
|
+
|
|
146
|
+
|
|
144
147
|
def _get_prefetch_pool():
|
|
145
148
|
global _PREFETCH_POOL
|
|
146
149
|
with _PREFETCH_LOCK:
|
|
147
150
|
if _PREFETCH_POOL is None or _PREFETCH_POOL._shutdown:
|
|
148
151
|
import concurrent.futures
|
|
149
152
|
|
|
153
|
+
# One pool is shared by every open session (see _schedule_prefetch).
|
|
154
|
+
# A single worker meant a second open array serialized behind the
|
|
155
|
+
# first array's lookahead, so scrolling any pane lagged. Size like
|
|
156
|
+
# _RENDER_WORKERS so a handful of concurrently open arrays each get
|
|
157
|
+
# a lane.
|
|
150
158
|
_PREFETCH_POOL = concurrent.futures.ThreadPoolExecutor(
|
|
151
|
-
max_workers=
|
|
159
|
+
max_workers=_PREFETCH_WORKERS, thread_name_prefix="arrayview-prefetch"
|
|
152
160
|
)
|
|
153
161
|
return _PREFETCH_POOL
|
|
154
162
|
|
|
@@ -674,6 +682,7 @@ __all__ = [
|
|
|
674
682
|
"_render",
|
|
675
683
|
# Prefetch
|
|
676
684
|
"_PREFETCH_POOL",
|
|
685
|
+
"_PREFETCH_WORKERS",
|
|
677
686
|
"_OVERLAY_PREFETCH_POOL",
|
|
678
687
|
"_PREFETCH_LOCK",
|
|
679
688
|
"_get_prefetch_pool",
|
|
@@ -713,6 +713,14 @@ def prepare_source(path: str, *, timeout: float | None = None) -> PreparedSource
|
|
|
713
713
|
"""Preflight and, for a network file, make a bounded local snapshot."""
|
|
714
714
|
absolute = lexical_abspath(path)
|
|
715
715
|
mount = mount_for_path(absolute)
|
|
716
|
+
if os.environ.get("ARRAYVIEW_SKIP_SOURCE_STAGING", "1") != "0":
|
|
717
|
+
# Network-source staging is opt-in, not the default: use the source
|
|
718
|
+
# path directly, exactly like a local file. This is the pre-staging
|
|
719
|
+
# behavior (fast open, but a dropped network mount while the file is
|
|
720
|
+
# memory-mapped can wedge the process in a way not even `kill -9`
|
|
721
|
+
# recovers from — see docs/loading.md). Set
|
|
722
|
+
# ARRAYVIEW_SKIP_SOURCE_STAGING=0 to restore the safety copy.
|
|
723
|
+
return PreparedSource(absolute, absolute, None, mount)
|
|
716
724
|
if mount is None or mount.filesystem not in _NETWORK_FILESYSTEMS:
|
|
717
725
|
unsafe_discovery = discovery_network_mount(absolute)
|
|
718
726
|
if unsafe_discovery is not None:
|