arrayview 0.43.1__py3-none-any.whl → 0.43.2__py3-none-any.whl

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/_extra_ports.py CHANGED
@@ -18,24 +18,23 @@ A port that has never been forwarded does not have that problem. So a cold
18
18
  launch is served from a fresh port, which VS Code forwards for the first time.
19
19
  This module adds that port to the process that is *already* running, rather than
20
20
  starting another server: same process, same loaded arrays, nothing extra to shut
21
- down, and repeat opens keep using the original port.
21
+ down, and overlapping opens share the same private viewer port.
22
22
 
23
23
  Lifetime
24
24
  --------
25
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.
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.
31
30
 
32
31
  It must not be closed while a viewer loaded from it is alive: the viewer keeps
33
32
  using HTTP on its own origin long after the page has rendered, so pulling the
34
33
  port out from under it would break the viewer rather than tidy up after it.
35
34
 
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.
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.
39
38
  """
40
39
 
41
40
  from __future__ import annotations
@@ -47,19 +46,20 @@ import time
47
46
  import arrayview._session as _session_mod
48
47
  from arrayview._session import _vprint
49
48
 
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"}
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"}
62
61
  _EXTRA_PORTS: dict[int, dict] = {}
62
+ _PORT_LOCK = asyncio.Lock()
63
63
 
64
64
 
65
65
  def extra_ports() -> list[int]:
@@ -76,18 +76,8 @@ def _bind_free_port() -> socket.socket:
76
76
  return sock
77
77
 
78
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
79
+ def _start_extra_port() -> tuple[int, dict] | None:
80
+ """Start the singleton extra listener; caller holds ``_PORT_LOCK``."""
91
81
  try:
92
82
  import uvicorn
93
83
 
@@ -105,57 +95,169 @@ async def open_extra_port() -> int | None:
105
95
  )
106
96
  server = uvicorn.Server(config)
107
97
  task = asyncio.create_task(server.serve(sockets=[sock]))
108
- _EXTRA_PORTS[port] = {
98
+ entry = {
109
99
  "socket": sock,
110
100
  "server": server,
111
101
  "task": task,
112
102
  "viewers": 0,
113
103
  "opened_at": time.monotonic(),
104
+ "leases": {},
105
+ "idle_until": 0.0,
106
+ "idle_generation": 0,
114
107
  }
115
- asyncio.create_task(_close_if_unused(port))
116
108
  # The socket is already listening and accepting before serve() runs, so
117
109
  # a client that connects immediately is queued rather than refused.
118
- _vprint(f"[ArrayView] serving a cold-start port on {port}")
119
- return port
110
+ _vprint(f"[ArrayView] serving the private viewer port on {port}")
111
+ return port, entry
120
112
  except Exception as exc: # pragma: no cover - defensive
121
- _vprint(f"[ArrayView] could not add a cold-start port: {exc}")
113
+ _vprint(f"[ArrayView] could not add the private viewer port: {exc}")
122
114
  return None
123
115
 
124
116
 
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
-
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.
131
119
 
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."""
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."""
134
165
  entry = _EXTRA_PORTS.get(port)
135
166
  if entry is None:
136
167
  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.
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
144
189
 
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
190
 
154
-
155
- async def _close_port(port: int) -> None:
156
- entry = _EXTRA_PORTS.pop(port, None)
157
- if entry is None:
158
- return
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."""
159
261
  try:
160
262
  entry["server"].should_exit = True
161
263
  await asyncio.wait_for(entry["task"], timeout=5)
@@ -166,7 +268,7 @@ async def _close_port(port: int) -> None:
166
268
  entry["socket"].close()
167
269
  except Exception:
168
270
  pass
169
- _vprint(f"[ArrayView] released cold-start port {port}")
271
+ _vprint(f"[ArrayView] released private viewer port {port}")
170
272
 
171
273
 
172
274
  async def close_extra_ports() -> None:
@@ -178,4 +280,4 @@ async def close_extra_ports() -> None:
178
280
  if not _EXTRA_PORTS or _session_mod.VIEWER_SOCKETS > 0:
179
281
  return
180
282
  for port in list(_EXTRA_PORTS):
181
- await _close_port(port)
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,
@@ -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] = (
arrayview/_server.py CHANGED
@@ -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")
arrayview/_viewer.html CHANGED
@@ -9564,6 +9564,11 @@
9564
9564
  }
9565
9565
  _normalizeCompareLayoutMode();
9566
9566
  applyCompareLayout();
9567
+ // Reconcile the diff/wipe pane visibility before scaling so the
9568
+ // incoming centre pane is already display:flex when its canvas is
9569
+ // measured — sizing it against a settled layout instead of a
9570
+ // display:none pane, which avoids a visible size jump on handoff.
9571
+ _reconcileCompareState();
9567
9572
 
9568
9573
  if (newMode === 0) {
9569
9574
  // Restore shared colorbar; hide per-pane colorbars and rescale to 2-column layout
@@ -9594,7 +9599,6 @@
9594
9599
  }
9595
9600
  _renderCompareCenterTitleControls();
9596
9601
  if (newMode > 0) _syncCompareCenterIsland();
9597
- _reconcileCompareState();
9598
9602
  _reconcileCbVisibility();
9599
9603
  _reconcileCompareCenterPicker();
9600
9604
  _reconcileCompareCenterModeBadge();
@@ -39803,11 +39807,15 @@
39803
39807
  if (!f0 || !f1) return null;
39804
39808
  const W = f0.width, H = f0.height;
39805
39809
  _wipeCanvas.width = W; _wipeCanvas.height = H;
39806
- const refCv = compareCanvases[0];
39807
- const cssW = parseInt(refCv.style.width) || refCv.offsetWidth || W;
39808
- const cssH = parseInt(refCv.style.height) || refCv.offsetHeight || H;
39809
- _wipeCanvas.style.width = cssW + 'px';
39810
- _wipeCanvas.style.height = cssH + 'px';
39810
+ if (!_isCompareBigLeftLayout()) {
39811
+ const refCv = compareCanvases[0];
39812
+ const cssW = parseInt(refCv.style.width) || refCv.offsetWidth || W;
39813
+ const cssH = parseInt(refCv.style.height) || refCv.offsetHeight || H;
39814
+ _wipeCanvas.style.width = cssW + 'px';
39815
+ _wipeCanvas.style.height = cssH + 'px';
39816
+ }
39817
+ const cssW = parseInt(_wipeCanvas.style.width) || _wipeCanvas.offsetWidth || W;
39818
+ const cssH = parseInt(_wipeCanvas.style.height) || _wipeCanvas.offsetHeight || H;
39811
39819
  return { f0, f1, W, H, cssW, cssH };
39812
39820
  }
39813
39821
 
@@ -348,7 +348,7 @@ def _open_browser(
348
348
  return OpenResult(
349
349
  OpenState.FAILED,
350
350
  "vscode-signal",
351
- "signal request was not written",
351
+ request.failure_reason or "signal request was not written",
352
352
  )
353
353
  if not blocking:
354
354
  return OpenResult(OpenState.ACCEPTED, "vscode-signal")
@@ -406,7 +406,7 @@ def _open_browser(
406
406
  return OpenResult(
407
407
  OpenState.FAILED,
408
408
  "vscode-signal",
409
- "signal request was not written",
409
+ request.failure_reason or "signal request was not written",
410
410
  )
411
411
  if blocking:
412
412
  ack = _wait_for_vscode_ack(
@@ -57,7 +57,7 @@ _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
57
57
  _VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
58
58
  _VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
59
59
  _VSCODE_EXT_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
60
- _VSCODE_EXT_VERSION = "0.15.45" # current bundled extension version
60
+ _VSCODE_EXT_VERSION = "0.15.48" # current bundled extension version
61
61
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
62
62
  # Version-skew notices already printed, so a long-lived process (Jupyter, a
63
63
  # script opening several arrays) states the mismatch once instead of per call.
@@ -54,6 +54,7 @@ class SignalRequest:
54
54
  ack_path: Path
55
55
  written: bool
56
56
  extension_version: str | None = None
57
+ failure_reason: str | None = None
57
58
 
58
59
  def __bool__(self) -> bool:
59
60
  return self.written
@@ -424,8 +425,14 @@ def _with_viewer_name(url: str, name: str | None) -> str:
424
425
  return url
425
426
 
426
427
 
427
- def _cold_start_url(url: str) -> str:
428
- """Serve a cold launch from a port VS Code has not forwarded before.
428
+ def _viewer_port_url(
429
+ url: str,
430
+ *,
431
+ request_id: str,
432
+ max_age_ms: int,
433
+ server_id: str | None,
434
+ ) -> str:
435
+ """Acquire the private tunnel viewer port for one correlated launch.
429
436
 
430
437
  Measured 2026-08-06, real host: a forward VS Code has just created never
431
438
  loses the viewer's page request (3/3 on genuine cold starts), while one that
@@ -433,11 +440,10 @@ def _cold_start_url(url: str) -> str:
433
440
  four. A dropped request never arrives, so the opener can only throw the tab
434
441
  away and open another — the flicker.
435
442
 
436
- While any viewer is open the server keeps the forward alive and this is
437
- skipped, so the ordinary case still reuses the one port. Only a launch with
438
- nothing connected pays for a fresh one, and the server releases it as soon
439
- as no viewer is left. Best effort throughout: a launch that cannot get a
440
- fresh port proceeds on the original one exactly as before.
443
+ The backend holds the selected port for this request until its correlated
444
+ viewer connects or the request expires. That lease prevents the last old
445
+ viewer from closing the port during the handoff. Failure is explicit: the
446
+ known-stale main port is not a safe fallback for a tunnel viewer.
441
447
  """
442
448
  import json as _json
443
449
  import urllib.request
@@ -446,28 +452,47 @@ def _cold_start_url(url: str) -> str:
446
452
  try:
447
453
  parts = urlsplit(url)
448
454
  if parts.hostname != "localhost" or not parts.port:
449
- return url
455
+ raise RuntimeError("the backend URL is not a private localhost route")
450
456
  origin = f"http://localhost:{parts.port}"
451
457
  with urllib.request.urlopen(f"{origin}/ping", timeout=1.0) as response:
452
458
  status = _json.load(response)
453
459
  if status.get("service") != "arrayview":
454
- return url
455
- if int(status.get("viewer_sockets") or 0) > 0:
456
- return url
460
+ raise RuntimeError("the target port is not an ArrayView server")
461
+ body = _json.dumps({
462
+ "requestId": request_id,
463
+ "ttlMs": max_age_ms,
464
+ "expectedServerId": server_id,
465
+ }).encode("utf-8")
457
466
  request = urllib.request.Request(
458
- f"{origin}/cold-start-port", data=b"", method="POST"
467
+ f"{origin}/cold-start-port",
468
+ data=body,
469
+ headers={"Content-Type": "application/json"},
470
+ method="POST",
459
471
  )
460
472
  with urllib.request.urlopen(request, timeout=3.0) as response:
461
- port = _json.load(response).get("port")
473
+ lease = _json.load(response)
474
+ if lease.get("requestId") != request_id:
475
+ raise RuntimeError(
476
+ "the running server does not support safe viewer handoff"
477
+ )
478
+ port = lease.get("port")
462
479
  if not port:
463
- return url
464
- _vprint(f"[ArrayView] cold start: serving this launch from port {port}")
480
+ raise RuntimeError("the server did not provide a viewer connection")
481
+ _vprint(f"[ArrayView] serving this launch from port {port}")
465
482
  return urlunsplit(
466
483
  (parts.scheme, f"localhost:{port}", parts.path, parts.query, parts.fragment)
467
484
  )
468
485
  except Exception as exc:
469
- _vprint(f"[ArrayView] cold-start port unavailable ({exc}); using the main port")
470
- return url
486
+ _vprint(f"[ArrayView] private viewer connection unavailable: {exc}")
487
+ if "does not support safe viewer handoff" in str(exc):
488
+ raise RuntimeError(
489
+ "ArrayView needs to restart once before it can open this viewer "
490
+ "safely; run `arrayview --kill`, then retry"
491
+ ) from exc
492
+ raise RuntimeError(
493
+ "ArrayView could not prepare the private VS Code viewer connection; "
494
+ "no display request was sent"
495
+ ) from exc
471
496
 
472
497
 
473
498
  def _open_via_signal_file(
@@ -489,8 +514,33 @@ def _open_via_signal_file(
489
514
  If omitted it is auto-derived from the session name embedded in the URL's
490
515
  ``?sid=`` query parameter.
491
516
  """
517
+ effective_max_age_ms = (
518
+ max(1, int(max_age_ms))
519
+ if max_age_ms is not None
520
+ else _VSCODE_SIGNAL_MAX_AGE_MS
521
+ )
522
+ request_id = uuid.uuid4().hex
523
+ ack_path = _vscode_ack_path(request_id)
492
524
  if cold_start_port:
493
- url = _cold_start_url(url)
525
+ try:
526
+ url = _viewer_port_url(
527
+ url,
528
+ request_id=request_id,
529
+ max_age_ms=effective_max_age_ms,
530
+ server_id=server_id,
531
+ )
532
+ except RuntimeError as exc:
533
+ from arrayview._vscode_extension import _VSCODE_EXT_VERSION
534
+
535
+ return SignalRequest(
536
+ request_id,
537
+ window_id,
538
+ server_id,
539
+ ack_path,
540
+ False,
541
+ _VSCODE_EXT_VERSION,
542
+ str(exc),
543
+ )
494
544
  session_name = _session_name_for_url(url)
495
545
  if title is None and session_name:
496
546
  title = f"ArrayView: {session_name}"
@@ -499,10 +549,8 @@ def _open_via_signal_file(
499
549
  # pre-formatted and others pass as a bare filename.
500
550
  url = _with_viewer_name(url, session_name)
501
551
 
502
- request_id = uuid.uuid4().hex
503
552
  if window_id is None:
504
553
  window_id = _find_arrayview_window_id()
505
- ack_path = _vscode_ack_path(request_id)
506
554
  _cleanup_stale_vscode_acks()
507
555
  try:
508
556
  ack_path.unlink()
@@ -511,11 +559,7 @@ def _open_via_signal_file(
511
559
  payload: dict = {
512
560
  "action": "open-preview",
513
561
  "url": url,
514
- "maxAgeMs": (
515
- max(1, int(max_age_ms))
516
- if max_age_ms is not None
517
- else _VSCODE_SIGNAL_MAX_AGE_MS
518
- ),
562
+ "maxAgeMs": effective_max_age_ms,
519
563
  "protocolVersion": _VSCODE_ACK_PROTOCOL_VERSION,
520
564
  "requestId": request_id,
521
565
  "ackPath": str(ack_path),
Binary file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.43.1
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
@@ -7,7 +7,7 @@ arrayview/_config.py,sha256=HEy3931e-pUzxen_Tn55xgpXfi-YSDSj-4K2w_eoZzI,13164
7
7
  arrayview/_diagnostics.py,sha256=XB0rCoDn3cpfhz4H-ayLktOgCMIoVJ0wM9N0ep_kKkQ,11018
8
8
  arrayview/_dicom.py,sha256=tNY353MNWTvcXavHeIkqj3242dbgfH_ctwcx9NDee-E,19607
9
9
  arrayview/_diff.py,sha256=mSIo1ArNxkrB0v2iUMy3dezJOlpos0xNu1V_umNUMhU,5677
10
- arrayview/_extra_ports.py,sha256=tW1IxAxSuJ1DOd1Z0_8XAOzUX5oou1-sVET6H1OcwvM,6852
10
+ arrayview/_extra_ports.py,sha256=Q-bH3jPcgQr8I2iecnU8cpcRkA9jz7g2WrtCWkiG-hI,11124
11
11
  arrayview/_icon.png,sha256=a8RTAbvweLhh3hzD5eGg76Peq-KSDcS6YrCphRb_HEs,2748
12
12
  arrayview/_imaging.py,sha256=MQX1uy4m1lofOeoLDhCp6cHO5p_V5fXm4qlSu9Ykn-c,534
13
13
  arrayview/_instance_registry.py,sha256=h7vyvCl0LTt9R8sDB1Vxe4a-KzfkLVuwrSw_ANpPTD8,8833
@@ -22,7 +22,7 @@ arrayview/_render.py,sha256=wjmyLrwmVF4bOjx4V-a0HgfjIIo9H502gdAEqQhRUCA,33589
22
22
  arrayview/_routes_analysis.py,sha256=b2yiPm3qiapFPcBZWpltZrsTKNkFyHYV7o1O2ZrzVUo,32320
23
23
  arrayview/_routes_drop.py,sha256=FC4e7STdCo3hYIA0C4Yy9Ka2UKFx7nLDU5dkujmc1Vo,12477
24
24
  arrayview/_routes_export.py,sha256=Ha8mMO2E9TlRL5x25-ADV8_8xUiC-jRz9N1oWwgKOsg,3898
25
- arrayview/_routes_loading.py,sha256=4AP2cHYIvZNjeD1lpT0jILHOUNnJ3CbuFzx9O-mMtHs,38917
25
+ arrayview/_routes_loading.py,sha256=s7Vc33Og9Cr2YrsFX7VP5lwjLY5Aa6fv9WoqQfJAsR8,39020
26
26
  arrayview/_routes_persistence.py,sha256=T03csHb7ppGT-8yMDf227XkqhnhjgSLYj0RW2wmg59g,17176
27
27
  arrayview/_routes_preferences.py,sha256=SKnhTf2X2vRyYi06Wb2si4dXMB1y0XiSBr28RZ5oKUw,4691
28
28
  arrayview/_routes_preload.py,sha256=KOsankNguxdIWmFW9iZcY9p2SvUN1mOKY7KkW1qMGcc,1729
@@ -31,9 +31,9 @@ arrayview/_routes_rendering.py,sha256=EQq2QzdtUS_pv35k7prLdlRdO5nByIlQ_Eu0nhVsn1
31
31
  arrayview/_routes_segmentation.py,sha256=x2bBjQffZryeTMmOH38YdBWethw1o3KpA8V0chJcZHI,23599
32
32
  arrayview/_routes_state.py,sha256=T_xoYcRlv7L47ydTxxiXb0Fp5M5x9KR2U-wkxaVeuMY,12462
33
33
  arrayview/_routes_vectorfield.py,sha256=OWZtAONwRKQHdYnbDAMbOVf0q6pH7kqV8fa1icDhKHM,9734
34
- arrayview/_routes_websocket.py,sha256=GRWA3OzL1ztywozO8QqhG-dt9vE1znHBp0MuUqbldB0,39736
34
+ arrayview/_routes_websocket.py,sha256=fq8NDAIQlgidaIVadyexQlyxVoXVw7ouW3TY_7VycP0,39755
35
35
  arrayview/_segmentation.py,sha256=aENWHmefrKDScXhcZL1X3KgYXE784dpMkmFFo83qnrw,7377
36
- arrayview/_server.py,sha256=fkoZFH0QKtS2ynpf7lpcdZDR-X-C6Mo0eS49CXEWN_M,18593
36
+ arrayview/_server.py,sha256=p0XB4n6G3sNBgTsXfnqbOl4It-0pth5wf0OETXUbbE4,20444
37
37
  arrayview/_session.py,sha256=ZuD-SJwp4H0rROwu-9MgHrULBUsq-6fiTS9zPHKUAw8,27090
38
38
  arrayview/_session_spec.py,sha256=mKDfMRsPYQdEmN9hLXmDU_9N7etiDyBXr8mz-fB8QBo,6825
39
39
  arrayview/_shell.html,sha256=vNa2Kiwx_eoB5H329jolYafLhwJDCH9DsJafBqvVMWU,13194
@@ -42,17 +42,17 @@ arrayview/_synthetic_mri.py,sha256=CNL-2WC835i6WCqkvmWBCO6WQ5w7HE5trNb8OyEfe64,7
42
42
  arrayview/_torch.py,sha256=13sqIMCUu_-b9SQ9KfDCExDz2UMStL87EDVjnbJxAEo,7536
43
43
  arrayview/_tutorial.py,sha256=U09j6hcps_dp3_azivvAoTChwqjNYGNLFqu9Cz1bOzE,6839
44
44
  arrayview/_vectorfield.py,sha256=eDqMCMEGjO8Jdt-6Auh01cz3WD9dbF7xYUWD9xHAf1E,8115
45
- arrayview/_viewer.html,sha256=-bw4d5eJ7nO0rBvJ3J8o-WPmzoLXGCy1R6c0IYebstk,2097505
45
+ arrayview/_viewer.html,sha256=JYkMXAkmPrP0WTvS53eaFCEeJ-ZXbBGaS1dYk2TkT7k,2098084
46
46
  arrayview/_vscode.py,sha256=EKnGN2zrjlybc4FQOmYA8BvYdtkcJbLHDEdgtvO7ul4,837
47
- arrayview/_vscode_browser.py,sha256=sfhQFJcIRjQvB_G9Kgnfoft96bLNhumpEGwIlBjxXTE,20080
48
- arrayview/_vscode_extension.py,sha256=-I12FyUzv3NL5h04eFKzlarWPi6lKNn2KBYhHMHkEXE,44477
49
- arrayview/_vscode_signal.py,sha256=8K9-0RGa5G3TlLS_M2aiXmERK65I8f5OuJICNPEZmSA,49832
47
+ arrayview/_vscode_browser.py,sha256=xypCgIwphII1Jgb6xC92gJu4Zo-oHtwjceymOqq4D_Y,20132
48
+ arrayview/_vscode_extension.py,sha256=dBQiP92h-2QH-sRbTtm3InAkxiIg_1YGswTmaLmXyvY,44477
49
+ arrayview/_vscode_signal.py,sha256=8kf6rHrowQ0P6HzHavqb1IWweKh-JyB_rq39v80Bkks,51366
50
50
  arrayview/_watch.py,sha256=PxxS_5hcj_S6xQf9Ep4p1HLRkrI5iDMh1aRa4olV7ew,1873
51
- arrayview/arrayview-opener.vsix,sha256=3kCNlIj_PIr4nImiBWS3d1eJgDz2AZ56gvK0uVHvrow,53581
51
+ arrayview/arrayview-opener.vsix,sha256=jQSEXjPTB2dMzKLVCvsdjUGvlOZV8cYVPPPA_YEndhM,54164
52
52
  arrayview/gsap.min.js,sha256=VGihm4idI0E1uqrWezWsiScB9F_jMosn7qs6pPSkeac,72304
53
53
  arrayview/todo.md,sha256=4rzoaIY4nJJACDHPJKeqP8guasCMxeRQ1cyNLxUYink,999
54
- arrayview-0.43.1.dist-info/METADATA,sha256=AV0dRemMvC7qFDq98-4yGj02_4jqrXx4xMCPtam-UnY,2515
55
- arrayview-0.43.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
56
- arrayview-0.43.1.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
57
- arrayview-0.43.1.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
58
- arrayview-0.43.1.dist-info/RECORD,,
54
+ arrayview-0.43.2.dist-info/METADATA,sha256=PmteArvJCUMkj5kkTtaeUntyGx2sqcg-T3tq0e5im1Q,2515
55
+ arrayview-0.43.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
56
+ arrayview-0.43.2.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
57
+ arrayview-0.43.2.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
58
+ arrayview-0.43.2.dist-info/RECORD,,