arrayview 0.43.1__tar.gz → 0.43.3__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 (113) hide show
  1. {arrayview-0.43.1 → arrayview-0.43.3}/PKG-INFO +1 -1
  2. {arrayview-0.43.1 → arrayview-0.43.3}/pyproject.toml +1 -1
  3. arrayview-0.43.3/src/arrayview/_extra_ports.py +283 -0
  4. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_loading.py +4 -1
  5. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_query.py +25 -10
  6. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_websocket.py +1 -1
  7. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_server.py +44 -9
  8. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_viewer.html +125 -14
  9. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_vscode_browser.py +2 -2
  10. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_vscode_extension.py +241 -9
  11. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_vscode_signal.py +69 -25
  12. arrayview-0.43.3/src/arrayview/arrayview-opener.vsix +0 -0
  13. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_api.py +435 -0
  14. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_browser.py +35 -0
  15. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_lifecycle_contract.py +197 -0
  16. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_vscode_ack_protocol.py +106 -0
  17. arrayview-0.43.1/src/arrayview/_extra_ports.py +0 -181
  18. arrayview-0.43.1/src/arrayview/arrayview-opener.vsix +0 -0
  19. {arrayview-0.43.1 → arrayview-0.43.3}/.gitignore +0 -0
  20. {arrayview-0.43.1 → arrayview-0.43.3}/LICENSE +0 -0
  21. {arrayview-0.43.1 → arrayview-0.43.3}/README.md +0 -0
  22. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/ARCHITECTURE.md +0 -0
  23. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/__init__.py +0 -0
  24. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/__main__.py +0 -0
  25. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_analysis.py +0 -0
  26. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_app.py +0 -0
  27. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_config.py +0 -0
  28. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_diagnostics.py +0 -0
  29. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_dicom.py +0 -0
  30. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_diff.py +0 -0
  31. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_icon.png +0 -0
  32. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_imaging.py +0 -0
  33. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_instance_registry.py +0 -0
  34. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_io.py +0 -0
  35. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_launch_plan.py +0 -0
  36. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_launch_trace.py +0 -0
  37. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_launcher.py +0 -0
  38. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_lifecycle.py +0 -0
  39. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_overlays.py +0 -0
  40. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_platform.py +0 -0
  41. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_render.py +0 -0
  42. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_analysis.py +0 -0
  43. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_drop.py +0 -0
  44. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_export.py +0 -0
  45. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_persistence.py +0 -0
  46. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_preferences.py +0 -0
  47. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_preload.py +0 -0
  48. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_rendering.py +0 -0
  49. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_segmentation.py +0 -0
  50. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_state.py +0 -0
  51. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_routes_vectorfield.py +0 -0
  52. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_segmentation.py +0 -0
  53. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_session.py +0 -0
  54. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_session_spec.py +0 -0
  55. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_shell.html +0 -0
  56. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_source_safety.py +0 -0
  57. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_synthetic_mri.py +0 -0
  58. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_torch.py +0 -0
  59. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_tutorial.py +0 -0
  60. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_vectorfield.py +0 -0
  61. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_vscode.py +0 -0
  62. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/_watch.py +0 -0
  63. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/gsap.min.js +0 -0
  64. {arrayview-0.43.1 → arrayview-0.43.3}/src/arrayview/todo.md +0 -0
  65. {arrayview-0.43.1 → arrayview-0.43.3}/tests/capture_patient_loading.py +0 -0
  66. {arrayview-0.43.1 → arrayview-0.43.3}/tests/capture_v_animation.py +0 -0
  67. {arrayview-0.43.1 → arrayview-0.43.3}/tests/conftest.py +0 -0
  68. {arrayview-0.43.1 → arrayview-0.43.3}/tests/lifecycle_matrix.py +0 -0
  69. {arrayview-0.43.1 → arrayview-0.43.3}/tests/make_vectorfield_test_arrays.py +0 -0
  70. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_1d_arrays.py +0 -0
  71. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_backend_shared.py +0 -0
  72. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_cli.py +0 -0
  73. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_colorbar_hover_highlight.py +0 -0
  74. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_config.py +0 -0
  75. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_dicom.py +0 -0
  76. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_display_opener_results.py +0 -0
  77. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_doctor_commands.py +0 -0
  78. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_drop_import.py +0 -0
  79. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_instance_registry.py +0 -0
  80. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_invocation_contract_matrix.py +0 -0
  81. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_io.py +0 -0
  82. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_large_arrays.py +0 -0
  83. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_launch_blackbox.py +0 -0
  84. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_launch_diagnostics.py +0 -0
  85. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_launch_execution_ownership.py +0 -0
  86. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_launch_plan.py +0 -0
  87. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_launch_trace.py +0 -0
  88. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_loading_server.py +0 -0
  89. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_mode_consistency.py +0 -0
  90. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_mode_matrix.py +0 -0
  91. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_mode_roundtrip.py +0 -0
  92. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_nifti_meta.py +0 -0
  93. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_nifti_series.py +0 -0
  94. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_preferences_ui.py +0 -0
  95. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_public_invocation_regressions.py +0 -0
  96. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_render_failure_reporting.py +0 -0
  97. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_render_progress.py +0 -0
  98. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_rgb_pixel_art.py +0 -0
  99. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_safe_server_stop.py +0 -0
  100. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_server_identity_status.py +0 -0
  101. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_session_spec.py +0 -0
  102. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_source_safety.py +0 -0
  103. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_spawn_ownership.py +0 -0
  104. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_torch.py +0 -0
  105. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_tutorial.py +0 -0
  106. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_tutorial_browser.py +0 -0
  107. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_value_formatting.py +0 -0
  108. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_view_handle_lifecycle.py +0 -0
  109. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_view_launch_execution.py +0 -0
  110. {arrayview-0.43.1 → arrayview-0.43.3}/tests/test_window_level_preview.py +0 -0
  111. {arrayview-0.43.1 → arrayview-0.43.3}/tests/ui_audit.py +0 -0
  112. {arrayview-0.43.1 → arrayview-0.43.3}/tests/v_anim_frames/.gitkeep +0 -0
  113. {arrayview-0.43.1 → arrayview-0.43.3}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.43.1
3
+ Version: 0.43.3
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.43.1"
7
+ version = "0.43.3"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,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)
@@ -832,7 +832,10 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
832
832
  )
833
833
  if require_display_ack:
834
834
  if not signal_request:
835
- raise RuntimeError("VS Code display request could not be written")
835
+ raise RuntimeError(
836
+ signal_request.failure_reason
837
+ or "VS Code display request could not be written"
838
+ )
836
839
  ack = await asyncio.to_thread(
837
840
  _wait_for_vscode_ack,
838
841
  signal_request,
@@ -51,6 +51,7 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
51
51
  if not token or len(token) > 256 or not window_id:
52
52
  raise HTTPException(status_code=400, detail="Incomplete viewer phase identity")
53
53
  allowed = {
54
+ "navigation-arrived",
54
55
  "script-loaded",
55
56
  "ws-open",
56
57
  "metadata-loaded",
@@ -58,13 +59,16 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
58
59
  }
59
60
  if phase != "launch-prepared" and phase not in allowed:
60
61
  raise HTTPException(status_code=400, detail="Invalid viewer phase")
61
- if phase != "launch-prepared" and not viewer_instance_id:
62
+ if (
63
+ phase not in {"launch-prepared", "navigation-arrived"}
64
+ and not viewer_instance_id
65
+ ):
62
66
  raise HTTPException(status_code=400, detail="Incomplete viewer phase identity")
63
67
  # Preparation and script startup are control-plane events. The viewer
64
68
  # is meant to open while a large array is still loading, so hiding
65
69
  # either event behind Session readiness creates a false pre-script
66
70
  # timeout. Data-dependent phases still wait for the real Session.
67
- if phase in {"launch-prepared", "script-loaded"}:
71
+ if phase in {"launch-prepared", "navigation-arrived", "script-loaded"}:
68
72
  if sid not in SESSIONS and sid not in _session_mod.PENDING_SESSIONS:
69
73
  raise HTTPException(status_code=404, detail="Session not found")
70
74
  else:
@@ -207,13 +211,24 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
207
211
  raise HTTPException(status_code=409, detail="Viewer phase owner changed")
208
212
  if body.get("sid") != sid:
209
213
  raise HTTPException(status_code=409, detail="Viewer primary session changed")
210
- related_sids = _viewer_related_sids(body, sid)
211
- if journal["related_sids"] is None:
212
- journal["related_sids"] = related_sids
213
- elif journal["related_sids"] != related_sids:
214
- raise HTTPException(status_code=409, detail="Viewer related sessions changed")
215
- if viewer_instance_id not in journal["viewer_instance_ids"]:
216
- journal["viewer_instance_ids"].append(viewer_instance_id)
214
+ if phase == "navigation-arrived":
215
+ navigation_attempt = body.get("navigation_attempt")
216
+ if (
217
+ type(navigation_attempt) is not int
218
+ or navigation_attempt != journal.get("navigation_attempt")
219
+ ):
220
+ raise HTTPException(
221
+ status_code=409,
222
+ detail="Viewer navigation attempt changed",
223
+ )
224
+ else:
225
+ related_sids = _viewer_related_sids(body, sid)
226
+ if journal["related_sids"] is None:
227
+ journal["related_sids"] = related_sids
228
+ elif journal["related_sids"] != related_sids:
229
+ raise HTTPException(status_code=409, detail="Viewer related sessions changed")
230
+ if viewer_instance_id not in journal["viewer_instance_ids"]:
231
+ journal["viewer_instance_ids"].append(viewer_instance_id)
217
232
  phases = journal["phases"]
218
233
  if phase not in phases:
219
234
  phases.append(phase)
@@ -245,7 +260,7 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
245
260
  "token": token,
246
261
  "phases": list(phases),
247
262
  "viewer_instance_ids": list(journal["viewer_instance_ids"]),
248
- "related_sids": list(journal["related_sids"]),
263
+ "related_sids": list(journal["related_sids"] or []),
249
264
  }
250
265
 
251
266
  @app.get("/viewer-phase/{sid}/{request_id}")
@@ -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
- 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.
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
- 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}
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")