arrayview 0.43.0__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/_diagnostics.py CHANGED
@@ -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
- _RETRY + " If it repeats, reload the VS Code window."),
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 "
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)
@@ -137,32 +137,33 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
137
137
  staging_dir = validated_staging_directory(abs_path, claimed_staging)
138
138
  if claimed_staging and staging_dir is None:
139
139
  raise HTTPException(status_code=400, detail="invalid source staging directory")
140
- unsafe_mount = direct_network_mount(abs_path)
141
- if unsafe_mount is not None:
142
- return {
143
- "error": (
144
- f"Direct server access to network mount {unsafe_mount.mountpoint!r} "
145
- "is refused. Launch the file through the arrayview CLI so it can "
146
- "be staged safely."
147
- )
148
- }
149
- nested_source_mount = network_mount_below(abs_path)
150
- if nested_source_mount is not None:
151
- return {
152
- "error": (
153
- f"Source discovery would enter network mount "
154
- f"{nested_source_mount.mountpoint!r}; copy the collection locally first."
155
- )
156
- }
157
- if source_may_require_sibling_discovery(abs_path):
158
- nested_sibling_mount = network_mount_below(os.path.dirname(abs_path))
159
- if nested_sibling_mount is not None:
140
+ if os.environ.get("ARRAYVIEW_SKIP_SOURCE_STAGING", "1") == "0":
141
+ unsafe_mount = direct_network_mount(abs_path)
142
+ if unsafe_mount is not None:
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"Related-file discovery would enter network mount "
163
- f"{nested_sibling_mount.mountpoint!r}; copy the series locally first."
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("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
+ )
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
- unsafe_mount = direct_network_mount(filepath)
83
- if unsafe_mount is not None:
84
- return {
85
- "error": (
86
- f"Direct server access to network mount {unsafe_mount.mountpoint!r} "
87
- "is refused; launch through the arrayview CLI."
88
- )
89
- }
90
- nested_mount = network_mount_below(filepath)
91
- if source_may_require_sibling_discovery(filepath):
92
- nested_mount = nested_mount or network_mount_below(
93
- os.path.dirname(filepath)
94
- )
95
- if nested_mount is not None:
96
- return {
97
- "error": (
98
- f"Vector-field discovery would enter network mount "
99
- f"{nested_mount.mountpoint!r}; copy it locally first."
82
+ if os.environ.get("ARRAYVIEW_SKIP_SOURCE_STAGING", "1") == "0":
83
+ unsafe_mount = direct_network_mount(filepath)
84
+ if unsafe_mount is not None:
85
+ return {
86
+ "error": (
87
+ f"Direct server access to network mount {unsafe_mount.mountpoint!r} "
88
+ "is refused; launch through the arrayview CLI."
89
+ )
90
+ }
91
+ nested_mount = network_mount_below(filepath)
92
+ if source_may_require_sibling_discovery(filepath):
93
+ nested_mount = nested_mount or network_mount_below(
94
+ os.path.dirname(filepath)
100
95
  )
101
- }
96
+ if nested_mount is not None:
97
+ return {
98
+ "error": (
99
+ f"Vector-field discovery would enter network mount "
100
+ f"{nested_mount.mountpoint!r}; copy it locally first."
101
+ )
102
+ }
102
103
  components_dim = body.get("components_dim")
103
104
  session = SESSIONS.get(sid)
104
105
  if not session:
@@ -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/_session.py CHANGED
@@ -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=1, thread_name_prefix="arrayview-prefetch"
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: