arrayview 0.35.3__tar.gz → 0.37.1__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 (107) hide show
  1. {arrayview-0.35.3 → arrayview-0.37.1}/PKG-INFO +1 -1
  2. {arrayview-0.35.3 → arrayview-0.37.1}/pyproject.toml +1 -1
  3. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_io.py +84 -9
  4. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_launcher.py +77 -30
  5. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_loading.py +9 -3
  6. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_query.py +8 -0
  7. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_session.py +48 -0
  8. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_viewer.html +64 -15
  9. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vscode_extension.py +46 -2
  10. arrayview-0.37.1/src/arrayview/arrayview-opener.vsix +0 -0
  11. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_api.py +3 -3
  12. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_cli.py +10 -2
  13. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_doctor_commands.py +30 -13
  14. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_instance_registry.py +3 -1
  15. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_invocation_contract_matrix.py +2 -0
  16. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_diagnostics.py +3 -0
  17. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_execution_ownership.py +4 -2
  18. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_plan.py +5 -2
  19. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_lifecycle_contract.py +15 -5
  20. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_public_invocation_regressions.py +1 -0
  21. arrayview-0.37.1/tests/test_value_formatting.py +126 -0
  22. arrayview-0.35.3/src/arrayview/arrayview-opener.vsix +0 -0
  23. {arrayview-0.35.3 → arrayview-0.37.1}/.gitignore +0 -0
  24. {arrayview-0.35.3 → arrayview-0.37.1}/LICENSE +0 -0
  25. {arrayview-0.35.3 → arrayview-0.37.1}/README.md +0 -0
  26. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/ARCHITECTURE.md +0 -0
  27. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/__init__.py +0 -0
  28. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/__main__.py +0 -0
  29. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_analysis.py +0 -0
  30. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_app.py +0 -0
  31. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_codex_open.py +0 -0
  32. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_config.py +0 -0
  33. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_dicom.py +0 -0
  34. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_diff.py +0 -0
  35. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_icon.png +0 -0
  36. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_imaging.py +0 -0
  37. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_instance_registry.py +0 -0
  38. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_launch_plan.py +0 -0
  39. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_launch_trace.py +0 -0
  40. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_lifecycle.py +0 -0
  41. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_overlays.py +0 -0
  42. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_platform.py +0 -0
  43. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_render.py +0 -0
  44. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_analysis.py +0 -0
  45. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_drop.py +0 -0
  46. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_export.py +0 -0
  47. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_persistence.py +0 -0
  48. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_preferences.py +0 -0
  49. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_preload.py +0 -0
  50. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_rendering.py +0 -0
  51. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_segmentation.py +0 -0
  52. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_state.py +0 -0
  53. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_vectorfield.py +0 -0
  54. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_websocket.py +0 -0
  55. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_segmentation.py +0 -0
  56. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_server.py +0 -0
  57. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_session_spec.py +0 -0
  58. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_shell.html +0 -0
  59. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_synthetic_mri.py +0 -0
  60. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_torch.py +0 -0
  61. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vectorfield.py +0 -0
  62. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vscode.py +0 -0
  63. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vscode_browser.py +0 -0
  64. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vscode_signal.py +0 -0
  65. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/gsap.min.js +0 -0
  66. {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/todo.md +0 -0
  67. {arrayview-0.35.3 → arrayview-0.37.1}/tests/capture_patient_loading.py +0 -0
  68. {arrayview-0.35.3 → arrayview-0.37.1}/tests/capture_v_animation.py +0 -0
  69. {arrayview-0.35.3 → arrayview-0.37.1}/tests/conftest.py +0 -0
  70. {arrayview-0.35.3 → arrayview-0.37.1}/tests/lifecycle_matrix.py +0 -0
  71. {arrayview-0.35.3 → arrayview-0.37.1}/tests/make_vectorfield_test_arrays.py +0 -0
  72. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_backend_shared.py +0 -0
  73. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_browser.py +0 -0
  74. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_codex_open.py +0 -0
  75. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_colorbar_hover_highlight.py +0 -0
  76. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_command_reachability.py +0 -0
  77. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_config.py +0 -0
  78. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_cross_mode_parametrized.py +0 -0
  79. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_dicom.py +0 -0
  80. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_display_opener_results.py +0 -0
  81. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_drop_import.py +0 -0
  82. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_interactions.py +0 -0
  83. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_io.py +0 -0
  84. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_large_arrays.py +0 -0
  85. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_blackbox.py +0 -0
  86. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_trace.py +0 -0
  87. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_loading_server.py +0 -0
  88. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_mode_consistency.py +0 -0
  89. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_mode_matrix.py +0 -0
  90. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_mode_roundtrip.py +0 -0
  91. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_nifti_meta.py +0 -0
  92. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_nifti_series.py +0 -0
  93. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_preferences_ui.py +0 -0
  94. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_rgb_pixel_art.py +0 -0
  95. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_safe_server_stop.py +0 -0
  96. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_server_identity_status.py +0 -0
  97. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_session_spec.py +0 -0
  98. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_spawn_ownership.py +0 -0
  99. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_torch.py +0 -0
  100. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_view_component_integration.py +0 -0
  101. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_view_component_unit.py +0 -0
  102. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_view_handle_lifecycle.py +0 -0
  103. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_view_launch_execution.py +0 -0
  104. {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_vscode_ack_protocol.py +0 -0
  105. {arrayview-0.35.3 → arrayview-0.37.1}/tests/ui_audit.py +0 -0
  106. {arrayview-0.35.3 → arrayview-0.37.1}/tests/v_anim_frames/.gitkeep +0 -0
  107. {arrayview-0.35.3 → arrayview-0.37.1}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.35.3
3
+ Version: 0.37.1
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.35.3"
7
+ version = "0.37.1"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -1397,6 +1397,76 @@ def _load_nifti_series(path, *, load="lazy", stack="auto"):
1397
1397
  return _series_from_file_matrix(file_matrix, load=load, stack=stack)
1398
1398
 
1399
1399
 
1400
+ _MMAP_PAGE_BYTES = 4096
1401
+
1402
+
1403
+ def npy_eager_limit() -> int:
1404
+ """Largest .npy that may be read fully into RAM."""
1405
+ return int(os.environ.get("AV_NPY_EAGER_BYTES", 2 * 1024**3))
1406
+
1407
+
1408
+ def first_frame_fault_cost(shape, itemsize, strides, dims, total_bytes):
1409
+ """Estimate what a memmap must fetch to render one 2D slice.
1410
+
1411
+ Returns ``(runs, bytes)``. Both matter, and on network storage the run
1412
+ count matters more: a frame of 210 single-page runs measured 1.34 s off an
1413
+ SMB share while transferring under a megabyte, because the cost was 210
1414
+ round-trips rather than the bytes.
1415
+
1416
+ Only the layout is consulted, so this touches no data. The displayed axes
1417
+ are ordered by stride; the inner one decides whether the frame arrives as a
1418
+ few long runs or as one page per element.
1419
+ """
1420
+ dx, dy = dims
1421
+ inner, outer = sorted(
1422
+ ((abs(int(strides[d])), int(shape[d])) for d in (dx, dy)),
1423
+ key=lambda pair: pair[0],
1424
+ )
1425
+ inner_stride, inner_len = inner
1426
+ outer_stride, outer_len = outer
1427
+
1428
+ row_span = inner_len * inner_stride
1429
+ if inner_stride == itemsize and outer_stride == row_span:
1430
+ # The displayed pair is itself one contiguous block (e.g. axes (0, 1) of
1431
+ # a Fortran-order array, or the trailing pair of a C-order one).
1432
+ return 1, min(row_span * outer_len, int(total_bytes))
1433
+ if inner_stride >= _MMAP_PAGE_BYTES:
1434
+ # Nothing shares a page: one fetch per element, the pathological case.
1435
+ runs = inner_len * outer_len
1436
+ return runs, min(runs * _MMAP_PAGE_BYTES, int(total_bytes))
1437
+ # Each row is one near-contiguous span; rows are scattered across the file.
1438
+ row_bytes = max(row_span, _MMAP_PAGE_BYTES)
1439
+ return outer_len, min(outer_len * row_bytes, int(total_bytes))
1440
+
1441
+
1442
+ def mmap_first_frame_is_cheap(arr, total_bytes) -> bool:
1443
+ """Whether serving the viewer's first frame off *arr* beats reading it whole.
1444
+
1445
+ A memmap wins when the axes the viewer starts on are the fastest-varying
1446
+ ones in the file layout, and loses badly when they are the slowest: the same
1447
+ 4D array costs a single contiguous block in Fortran order and roughly one
1448
+ page per element in C order. Both regimes are common, so decide per array
1449
+ rather than assuming either.
1450
+ """
1451
+ if os.environ.get("AV_MMAP_FIRST_FRAME", "1") == "0":
1452
+ return False
1453
+ from ._session import _viewer_start_dims_for_shape
1454
+
1455
+ shape = tuple(int(s) for s in getattr(arr, "shape", ()))
1456
+ strides = tuple(int(s) for s in getattr(arr, "strides", ()))
1457
+ if len(shape) < 2 or len(strides) != len(shape) or not total_bytes:
1458
+ return False
1459
+ dims = _viewer_start_dims_for_shape(shape)
1460
+ if dims is None:
1461
+ return False
1462
+ runs, faulted = first_frame_fault_cost(
1463
+ shape, arr.dtype.itemsize, strides, dims, total_bytes
1464
+ )
1465
+ max_runs = int(os.environ.get("AV_MMAP_FIRST_FRAME_MAX_RUNS", "1024"))
1466
+ fraction = float(os.environ.get("AV_MMAP_FIRST_FRAME_FRACTION", "0.25"))
1467
+ return runs <= max_runs and faulted <= fraction * total_bytes
1468
+
1469
+
1400
1470
  def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select=None):
1401
1471
  """Like load_data but also returns spatial metadata for NIfTI files.
1402
1472
 
@@ -1430,15 +1500,20 @@ def load_data(filepath, key=None):
1430
1500
  series, _meta = _load_file_series(filepath)
1431
1501
  return series
1432
1502
  if filepath.endswith(".npy"):
1433
- # Eager-load small-to-medium files into RAM. mmap_mode="r" on a C-order
1434
- # 4D+ array forces scattered page faults for every orthogonal slice
1435
- # (elements are thousands of bytes apart), making first renders very slow.
1436
- # Sequential read is much faster and the PENDING_SESSIONS background thread
1437
- # hides the upfront cost. Keep mmap only for files that don't fit in RAM.
1438
- _NPY_EAGER_LIMIT = int(os.environ.get("AV_NPY_EAGER_BYTES", 2 * 1024**3))
1439
- if os.path.getsize(filepath) < _NPY_EAGER_LIMIT:
1440
- return np.load(filepath)
1441
- return np.load(filepath, mmap_mode="r")
1503
+ # Files that do not fit in RAM must be mapped whatever the access cost.
1504
+ # Below that limit the array ends up in RAM either way; the only question
1505
+ # is whether the first frame is served from the map while the sequential
1506
+ # read runs behind it (see mmap_first_frame_is_cheap) or the viewer shows
1507
+ # a spinner until the read completes.
1508
+ size = os.path.getsize(filepath)
1509
+ if size >= npy_eager_limit():
1510
+ return np.load(filepath, mmap_mode="r")
1511
+ mapped = np.load(filepath, mmap_mode="r")
1512
+ if mmap_first_frame_is_cheap(mapped, size):
1513
+ # Session creation upgrades this to an in-RAM array in the background.
1514
+ return mapped
1515
+ del mapped
1516
+ return np.load(filepath)
1442
1517
  elif filepath.endswith(".npz"):
1443
1518
  npz = np.load(filepath)
1444
1519
  try:
@@ -1672,7 +1672,7 @@ def _register_cli_session_with_existing_server_impl(
1672
1672
  error += (
1673
1673
  "\nThe server on this port is an older ArrayView process that "
1674
1674
  "does not support automatic --overlay-dir case inference yet. "
1675
- f"Restart it with: arrayview --kill --port {port}"
1675
+ "Restart it with: arrayview stop"
1676
1676
  )
1677
1677
  raise RuntimeError(f"Error from server: {error}")
1678
1678
  _loaded_sids.append(str(result["sid"]))
@@ -1860,7 +1860,7 @@ def _handle_cli_existing_server(
1860
1860
  print(
1861
1861
  f"Error: existing ArrayView server on port {port} does not support "
1862
1862
  "directory stacking. Restart it with "
1863
- f"`arrayview --kill --port {port}` or choose a free port with "
1863
+ "`arrayview stop` or choose a free port with "
1864
1864
  f"`--port`. ({e})"
1865
1865
  )
1866
1866
  sys.exit(1)
@@ -2590,6 +2590,11 @@ _PERSIST_DAEMON_CONNECT_TIMEOUT_SECONDS = float(
2590
2590
  _PERSIST_DAEMON_IDLE_SECONDS = float(
2591
2591
  os.environ.get("ARRAYVIEW_PERSIST_IDLE_SECONDS", "1800")
2592
2592
  )
2593
+ # How long a daemon whose initial load failed keeps serving so the opener can
2594
+ # read the reason from /metadata/<sid> before the port is released.
2595
+ _FAILED_LOAD_EXIT_GRACE_SECONDS = float(
2596
+ os.environ.get("ARRAYVIEW_FAILED_LOAD_EXIT_GRACE_SECONDS", "30")
2597
+ )
2593
2598
 
2594
2599
 
2595
2600
  def _join_query_values(values: _CSVValues) -> str:
@@ -4169,6 +4174,7 @@ def _serve_daemon(
4169
4174
  owner="persistent" if persist else "transient",
4170
4175
  )
4171
4176
  # Register sid as pending so /metadata can poll while data loads.
4177
+ _initial_load_failed = threading.Event()
4172
4178
  _session_mod.PENDING_SESSIONS.add(sid)
4173
4179
  _pending_event = threading.Event()
4174
4180
  _session_mod.PENDING_SESSION_EVENTS[sid] = _pending_event
@@ -4301,6 +4307,7 @@ def _serve_daemon(
4301
4307
  f"[ArrayView] Warning: failed to load vector field {vfield_filepath}: {e}",
4302
4308
  flush=True,
4303
4309
  )
4310
+ _session_mod.upgrade_memmap_in_background(session)
4304
4311
  _session_mod.SESSIONS[session.sid] = session
4305
4312
  if dir_overlay_items is not None:
4306
4313
  for item, ov_sid in zip(dir_overlay_items, overlay_sids or []):
@@ -4349,6 +4356,18 @@ def _serve_daemon(
4349
4356
  sid_tag=sid_tag,
4350
4357
  error_type=type(exc).__name__,
4351
4358
  )
4359
+ # This runs on a daemon thread, so re-raising only kills the thread
4360
+ # and writes to a stderr nobody reads. Record why the sid will never
4361
+ # become ready so /metadata can report it instead of returning 404
4362
+ # until the opener gives up.
4363
+ _session_mod.FAILED_PENDING_SESSIONS[sid] = (
4364
+ str(exc) or type(exc).__name__
4365
+ )
4366
+ _vprint(
4367
+ f"[ArrayView] Failed to load {filepath}: {exc}",
4368
+ flush=True,
4369
+ )
4370
+ _initial_load_failed.set()
4352
4371
  raise
4353
4372
  finally:
4354
4373
  _session_mod.PENDING_SESSIONS.discard(sid)
@@ -4357,6 +4376,32 @@ def _serve_daemon(
4357
4376
 
4358
4377
  threading.Thread(target=_load, daemon=True).start()
4359
4378
 
4379
+ def _exit_if_initial_load_failed() -> None:
4380
+ """Release the port when the file this daemon exists to serve won't load.
4381
+
4382
+ uvicorn starts before the load finishes, so a failed load otherwise
4383
+ leaves a server with no session holding the port until the connect
4384
+ timeout expires minutes later — and answering /ping the whole time. The
4385
+ grace period is only so the opener can read the failure from
4386
+ /metadata/<sid> before the port goes away.
4387
+ """
4388
+ _initial_load_failed.wait()
4389
+ time.sleep(_FAILED_LOAD_EXIT_GRACE_SECONDS)
4390
+ if _session_mod.SESSIONS or _session_mod.VIEWER_SOCKETS:
4391
+ return # something else is using this server; leave it alone
4392
+ _trace_launch_event(
4393
+ "daemon.exiting",
4394
+ reason="initial_load_failed",
4395
+ instance_tag=_launch_trace_tag(record.instance_id),
4396
+ )
4397
+ try:
4398
+ registry.remove(record.instance_id)
4399
+ except Exception:
4400
+ pass
4401
+ os._exit(0)
4402
+
4403
+ threading.Thread(target=_exit_if_initial_load_failed, daemon=True).start()
4404
+
4360
4405
  if persist:
4361
4406
  _wait_for_viewer_close(
4362
4407
  idle_seconds=_PERSIST_DAEMON_IDLE_SECONDS,
@@ -4771,9 +4816,29 @@ def _instance_public_dict(record) -> dict[str, object]:
4771
4816
  return value
4772
4817
 
4773
4818
 
4819
+ def _run_stop_command() -> None:
4820
+ """Stop every running ArrayView instance and clean up stale state."""
4821
+ from arrayview._instance_registry import InstanceRegistry # noqa: PLC0415
4822
+
4823
+ records = InstanceRegistry().discover(clean_stale=True)
4824
+ if not records:
4825
+ print("[ArrayView] No running ArrayView instances.")
4826
+ for record in records:
4827
+ message, _pid = _stop_verified_server(record.port)
4828
+ print(f"[ArrayView] {record.instance_id}: {message}")
4829
+
4830
+ from arrayview._vscode_signal import ( # noqa: PLC0415
4831
+ _cleanup_zombie_registrations,
4832
+ )
4833
+
4834
+ cleaned = _cleanup_zombie_registrations(verbose=True)
4835
+ if cleaned:
4836
+ print(f"[ArrayView] Cleaned {cleaned} zombie window registration(s)")
4837
+
4838
+
4774
4839
  def _handle_management_command(argv: list[str]) -> bool:
4775
4840
  """Handle dependency-light management subcommands before the file CLI."""
4776
- if not argv or argv[0] not in {"doctor", "instances", "stop"}:
4841
+ if not argv or argv[0] not in {"doctor", "instances", "stop", "kill"}:
4777
4842
  return False
4778
4843
  command = argv[0]
4779
4844
  parser = argparse.ArgumentParser(prog=f"arrayview {command}")
@@ -4799,22 +4864,10 @@ def _handle_management_command(argv: list[str]) -> bool:
4799
4864
  )
4800
4865
  return True
4801
4866
 
4802
- if command == "stop":
4803
- group = parser.add_mutually_exclusive_group(required=True)
4804
- group.add_argument("instance_id", nargs="?")
4805
- group.add_argument("--all", action="store_true")
4806
- args = parser.parse_args(argv[1:])
4807
- records = InstanceRegistry().discover(clean_stale=True)
4808
- selected = (
4809
- records
4810
- if args.all
4811
- else [record for record in records if record.instance_id == args.instance_id]
4812
- )
4813
- if not selected:
4814
- parser.error("no matching running ArrayView instance")
4815
- for record in selected:
4816
- message, _pid = _stop_verified_server(record.port)
4817
- print(f"[ArrayView] {record.instance_id}: {message}")
4867
+ if command in {"stop", "kill"}:
4868
+ parser.description = "Stop every running ArrayView server."
4869
+ parser.parse_args(argv[1:])
4870
+ _run_stop_command()
4818
4871
  return True
4819
4872
 
4820
4873
  parser.add_argument("--json", action="store_true")
@@ -4958,7 +5011,7 @@ def arrayview():
4958
5011
  parser.add_argument(
4959
5012
  "--kill",
4960
5013
  action="store_true",
4961
- help="Kill the ArrayView server running on --port (default 8000) and exit",
5014
+ help="Deprecated: use `arrayview stop`",
4962
5015
  )
4963
5016
  parser.add_argument(
4964
5017
  "--overlay",
@@ -5306,15 +5359,9 @@ def arrayview():
5306
5359
  sys.exit(1)
5307
5360
  return
5308
5361
 
5309
- # -- --kill: stop the server on the given port --
5362
+ # -- --kill: deprecated alias for `arrayview stop` --
5310
5363
  if args.kill:
5311
- message, _pid = _stop_verified_server(args.port)
5312
- print(f"[ArrayView] {message}")
5313
-
5314
- from arrayview._vscode_signal import _cleanup_zombie_registrations
5315
- cleaned = _cleanup_zombie_registrations(verbose=True)
5316
- if cleaned:
5317
- print(f"[ArrayView] Cleaned {cleaned} zombie window registration(s)")
5364
+ _run_stop_command()
5318
5365
  return
5319
5366
 
5320
5367
  # -- --serve: start a persistent empty server and exit --
@@ -5359,7 +5406,7 @@ def arrayview():
5359
5406
  if serve_context.evidence.is_vscode_remote:
5360
5407
  print(
5361
5408
  f"[ArrayView] Port {args.port} was claimed while starting.\n"
5362
- f" Run 'arrayview --kill --port {args.port}' to free it, "
5409
+ " Run 'arrayview stop' to free it, "
5363
5410
  "or use --port to specify a different port.",
5364
5411
  flush=True,
5365
5412
  )
@@ -5631,7 +5678,7 @@ def arrayview():
5631
5678
  if launch_plan.failure is LaunchFailure.REMOTE_PORT_CONFLICT:
5632
5679
  print(
5633
5680
  f"[ArrayView] Port {args.port} is in use by another process.\n"
5634
- f" Run 'arrayview --kill --port {args.port}' to free it, "
5681
+ " Run 'arrayview stop' to free it, "
5635
5682
  f"or use --port to specify a different port.",
5636
5683
  flush=True,
5637
5684
  )
@@ -18,11 +18,13 @@ from arrayview._lifecycle import (
18
18
  )
19
19
  from arrayview._session import (
20
20
  CANCELLED_PENDING_SESSIONS,
21
+ FAILED_PENDING_SESSIONS,
21
22
  PENDING_SESSION_EVENTS,
22
23
  PENDING_SESSIONS,
23
24
  SESSIONS,
24
25
  Session,
25
26
  file_signature,
27
+ upgrade_memmap_in_background,
26
28
  wait_for_session_ready,
27
29
  )
28
30
 
@@ -263,6 +265,7 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
263
265
  # the real Session instead of seeing a transient 404.
264
266
  sid = requested_sid or uuid.uuid4().hex
265
267
  pending_event = threading.Event()
268
+ FAILED_PENDING_SESSIONS.pop(sid, None)
266
269
  PENDING_SESSIONS.add(sid)
267
270
  PENDING_SESSION_EVENTS[sid] = pending_event
268
271
  signature_before_load = file_signature(abs_path)
@@ -291,14 +294,17 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
291
294
  session.related_release_sids = [
292
295
  str(value) for value in body.get("related_sids", [])
293
296
  ]
297
+ upgrade_memmap_in_background(session)
294
298
  commit_pending_session(sid, session)
295
299
  except Exception as exc:
296
- # Keep the server alive and make the failure visible in its
297
- # log. The opener will fail closed when metadata never
298
- # becomes ready, while a retry creates a fresh request.
300
+ # Keep the server alive, and record why this sid will never
301
+ # become ready. Without the record the opener cannot tell a
302
+ # failed load from a slow one and burns its whole deadline
303
+ # on a file that raised immediately.
299
304
  import traceback
300
305
 
301
306
  traceback.print_exc()
307
+ FAILED_PENDING_SESSIONS[sid] = str(exc) or exc.__class__.__name__
302
308
  finally:
303
309
  PENDING_SESSIONS.discard(sid)
304
310
  CANCELLED_PENDING_SESSIONS.discard(sid)
@@ -211,6 +211,14 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
211
211
  # request; the opener retries until the pending Session is published.
212
212
  if sid in _session_mod.PENDING_SESSIONS:
213
213
  return Response(status_code=404, headers={"Retry-After": "1"})
214
+ # A load that raised is terminal. Say so with a distinct status and the
215
+ # real reason, so the caller stops polling and can show the user why
216
+ # this file will never open instead of waiting out its deadline.
217
+ load_error = _session_mod.FAILED_PENDING_SESSIONS.get(sid)
218
+ if load_error is not None:
219
+ return Response(
220
+ status_code=422, content=load_error.encode(), media_type="text/plain"
221
+ )
214
222
  session = await wait_for_session_ready(sid)
215
223
  if not session:
216
224
  return Response(status_code=404)
@@ -41,6 +41,10 @@ _window_process = None
41
41
  PENDING_SESSIONS: set = set() # sids whose data is still loading in a background thread
42
42
  PENDING_SESSION_EVENTS: dict[str, threading.Event] = {}
43
43
  CANCELLED_PENDING_SESSIONS: set[str] = set()
44
+ # sid -> why its background load failed. A load that raises must be reportable:
45
+ # without this the opener cannot tell "still loading" from "will never load" and
46
+ # waits out its whole deadline on a file that failed in the first second.
47
+ FAILED_PENDING_SESSIONS: dict[str, str] = {}
44
48
 
45
49
 
46
50
  def file_signature(filepath: str):
@@ -454,6 +458,49 @@ def _startup_dims_for_data(data, shape=None) -> tuple[int, int] | None:
454
458
  return _viewer_start_dims_for_shape(target_shape)
455
459
 
456
460
 
461
+ def upgrade_memmap_in_background(session) -> bool:
462
+ """Read a memmap-backed session into RAM behind the viewer.
463
+
464
+ ``load_data`` hands back a memmap only when the first frame is cheap to
465
+ fault in, which buys an immediate picture but leaves later orientations
466
+ paying scattered reads. The sequential read still has to happen, so run it
467
+ off the hot path and swap the array in when it lands.
468
+
469
+ Rebinding ``session.data`` is atomic under the GIL and the values are
470
+ identical, so a render already holding the memmap stays correct and the
471
+ slice caches stay valid.
472
+ """
473
+ data = getattr(session, "data", None)
474
+ filepath = getattr(session, "filepath", None)
475
+ if not isinstance(data, np.memmap) or not filepath:
476
+ return False
477
+ if not str(filepath).endswith(".npy"):
478
+ return False
479
+ try:
480
+ from ._io import npy_eager_limit
481
+
482
+ if os.path.getsize(filepath) >= npy_eager_limit():
483
+ return False # does not fit in RAM — the map is the only option
484
+ except OSError:
485
+ return False
486
+
487
+ expected = data
488
+ version = getattr(session, "data_version", 0)
489
+
490
+ def _upgrade():
491
+ try:
492
+ loaded = np.load(filepath)
493
+ except Exception:
494
+ return # keep serving from the map
495
+ # A /reload or a released session must not be clobbered by a read that
496
+ # started against the previous array.
497
+ if session.data is expected and getattr(session, "data_version", 0) == version:
498
+ session.data = loaded
499
+
500
+ threading.Thread(target=_upgrade, daemon=True).start()
501
+ return True
502
+
503
+
457
504
  SESSIONS = {}
458
505
 
459
506
  COLORMAPS = [
@@ -575,6 +622,7 @@ __all__ = [
575
622
  "_window_process",
576
623
  "PENDING_SESSIONS",
577
624
  "PENDING_SESSION_EVENTS",
625
+ "FAILED_PENDING_SESSIONS",
578
626
  "wait_for_session_ready",
579
627
  # Render thread pool
580
628
  "_RENDER_QUEUE",
@@ -9278,7 +9278,20 @@
9278
9278
  function formatValueGroup(values, options = {}) {
9279
9279
  const finite = (Array.isArray(values) ? values : [values]).filter(Number.isFinite);
9280
9280
  const cap = Math.max(0, Math.min(8, Math.trunc(options.maxDecimals ?? 7)));
9281
- const minDecimals = Math.max(0, Math.min(cap, Math.trunc(options.minDecimals ?? 2)));
9281
+ // Decimals worth showing depend on magnitude. Two was applied at
9282
+ // every scale, so a range topping out near 1890 read as "1890.04"
9283
+ // when the hundredths carry no information. The floor is only ever
9284
+ // lowered for large values, never raised, so small ranges keep the
9285
+ // precision they had. The search below can still add decimals when
9286
+ // values would otherwise collide.
9287
+ const _defaultMinDecimals = (vals) => {
9288
+ const m = Math.max(...vals.filter(Number.isFinite).map(v => Math.abs(v)));
9289
+ if (!(m > 0) || !Number.isFinite(m)) return 2;
9290
+ return Math.max(0, Math.min(2, 3 - Math.floor(Math.log10(m))));
9291
+ };
9292
+ const minDecimals = Math.max(0, Math.min(cap, Math.trunc(
9293
+ options.minDecimals ?? _defaultMinDecimals(finite)
9294
+ )));
9282
9295
  const ordinaryLo = options.ordinaryLo ?? 1e-4;
9283
9296
  const ordinaryHi = options.ordinaryHi ?? 1e6;
9284
9297
  const fallback = {
@@ -14242,6 +14255,41 @@
14242
14255
 
14243
14256
  /* ── JS: Initialization and Metadata Fetch ────────────────── */
14244
14257
 
14258
+ // The backend serves this page as soon as its port is up, which can be
14259
+ // seconds before a large array has finished loading. While the session
14260
+ // is still loading /metadata answers 404 with a Retry-After header; an
14261
+ // unknown or released sid answers 404 without one, and a load that
14262
+ // failed answers 422 with the reason. Keep waiting only for the first.
14263
+ async function fetchMetadataWithRetry(sid, timeoutMs = 180000) {
14264
+ const deadline = Date.now() + timeoutMs;
14265
+ for (;;) {
14266
+ const res = await proxyFetch(`/metadata/${sid}`);
14267
+ if (res.ok) return { data: await res.json() };
14268
+ if (res.status === 422) {
14269
+ let reason = '';
14270
+ try { reason = (await res.text()).trim(); } catch (_e) {}
14271
+ return { error: reason || 'This file could not be loaded.', status: 422 };
14272
+ }
14273
+ const pending = res.status === 404 && res.headers.get('Retry-After');
14274
+ if (!pending || Date.now() >= deadline) {
14275
+ return { error: null, status: res.status };
14276
+ }
14277
+ await new Promise(r => setTimeout(r, 300));
14278
+ }
14279
+ }
14280
+
14281
+ function showMetadataError(result) {
14282
+ const msg = result.error
14283
+ ? result.error
14284
+ : (result.status === 404
14285
+ ? 'Session not found or expired.'
14286
+ : `Server error ${result.status} — check Python console for details.`);
14287
+ document.getElementById('loading-overlay').innerHTML =
14288
+ `<div style="color:#c55;font-size:16px;font-family:monospace;max-width:60ch;text-align:center;line-height:1.5;padding:0 2em">${
14289
+ msg.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
14290
+ }</div>`;
14291
+ }
14292
+
14245
14293
  async function init() {
14246
14294
  // Loading spinner is in static HTML — body.av-loading set by server template.
14247
14295
  if (!sid) { hideLoadingOverlay(); drawPlasmaDemo(); return; }
@@ -14260,26 +14308,24 @@
14260
14308
  );
14261
14309
  data = await Promise.race([_metadataFromWs, httpFallback]);
14262
14310
  if (!data) {
14263
- // Both failed — try direct HTTP without timeout
14264
- const res = await proxyFetch(`/metadata/${sid}`);
14265
- if (!res.ok) {
14266
- reportParent('metadata-http-error', { status: res.status, sid });
14267
- const msg = res.status === 404 ? 'Session not found or expired.' : `Server error ${res.status} — check Python console for details.`;
14268
- document.getElementById('loading-overlay').innerHTML = `<div style="color:#c55;font-size:18px;font-family:monospace">${msg}</div>`;
14311
+ // Both failed — wait out a still-loading session.
14312
+ const result = await fetchMetadataWithRetry(sid);
14313
+ if (!result.data) {
14314
+ reportParent('metadata-http-error', { status: result.status, sid });
14315
+ showMetadataError(result);
14269
14316
  return;
14270
14317
  }
14271
- data = await res.json();
14318
+ data = result.data;
14272
14319
  }
14273
14320
  } else {
14274
14321
  // HTTP transport mode (VS Code) — use traditional fetch
14275
- const res = await proxyFetch(`/metadata/${sid}`);
14276
- if (!res.ok) {
14277
- reportParent('metadata-http-error', { status: res.status, sid });
14278
- const msg = res.status === 404 ? 'Session not found or expired.' : `Server error ${res.status} — check Python console for details.`;
14279
- document.getElementById('loading-overlay').innerHTML = `<div style="color:#c55;font-size:18px;font-family:monospace">${msg}</div>`;
14322
+ const result = await fetchMetadataWithRetry(sid);
14323
+ if (!result.data) {
14324
+ reportParent('metadata-http-error', { status: result.status, sid });
14325
+ showMetadataError(result);
14280
14326
  return;
14281
14327
  }
14282
- data = await res.json();
14328
+ data = result.data;
14283
14329
  }
14284
14330
 
14285
14331
  await _loadOverlayLabelValues();
@@ -35919,9 +35965,12 @@
35919
35965
  return `${x.toFixed(1)},${y.toFixed(1)}`;
35920
35966
  }).join(' ');
35921
35967
  const distLabel = distance.toFixed(1) + ' px';
35968
+ // Share the colorbar's formatter so the same value does not read
35969
+ // differently here than it does on the colorbar.
35970
+ const _lpFmt = formatValueGroup([vMin, vMax]);
35922
35971
  const fmtVal = v => {
35923
35972
  if (v === null || !isFinite(v)) return '?';
35924
- return Math.abs(v) < 0.01 || Math.abs(v) >= 1e4 ? v.toExponential(2) : v.toFixed(2);
35973
+ return _lpFmt.format(v);
35925
35974
  };
35926
35975
  _lineProfileOverlay.innerHTML = `
35927
35976
  <svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
@@ -54,7 +54,7 @@ _VSCODE_EXT_INSTALLED = False # cached so we only check once per process
54
54
  _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
55
55
  _VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
56
56
  _VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
57
- _VSCODE_EXT_VERSION = "0.14.80" # current bundled extension version
57
+ _VSCODE_EXT_VERSION = "0.14.88" # current bundled extension version
58
58
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
59
59
 
60
60
  def _bundled_vscode_vsix_version(vsix_path: str) -> str | None:
@@ -633,6 +633,47 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
633
633
  # ---------------------------------------------------------------------------
634
634
 
635
635
 
636
+ def _port_has_listener(port: int) -> bool:
637
+ """Return True when something is listening on ``port`` on loopback."""
638
+ import socket
639
+
640
+ for family, addr in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")):
641
+ sock = socket.socket(family, socket.SOCK_STREAM)
642
+ try:
643
+ sock.settimeout(0.15)
644
+ if sock.connect_ex((addr, port)) == 0:
645
+ return True
646
+ except OSError:
647
+ continue
648
+ finally:
649
+ sock.close()
650
+ return False
651
+
652
+
653
+ def _stale_arrayview_port_keys(attrs: dict, keep_port: int) -> list[str]:
654
+ """Return ArrayView port entries that no longer have a live server.
655
+
656
+ Every launch on a fresh port used to leave its entry behind for good. On a
657
+ tunnel those entries keep telling VS Code to forward ports nothing serves,
658
+ which competes for a limited per-tunnel forwarding budget. Only entries
659
+ ArrayView wrote are considered, and only when their port is dead, so a
660
+ second concurrent ArrayView server is never disturbed.
661
+ """
662
+ stale: list[str] = []
663
+ for key, value in list(attrs.items()):
664
+ if not isinstance(value, dict) or value.get("label") != "ArrayView":
665
+ continue
666
+ if key == str(keep_port):
667
+ continue
668
+ try:
669
+ candidate = int(key)
670
+ except (TypeError, ValueError):
671
+ continue
672
+ if not _port_has_listener(candidate):
673
+ stale.append(key)
674
+ return stale
675
+
676
+
636
677
  def _configure_vscode_port_preview(
637
678
  port: int,
638
679
  *,
@@ -686,8 +727,11 @@ def _configure_vscode_port_preview(
686
727
  }
687
728
  current = attrs.get(str(port))
688
729
  updated = {**current, **desired} if isinstance(current, dict) else desired
689
- if current == updated:
730
+ stale = _stale_arrayview_port_keys(attrs, port)
731
+ if current == updated and not stale:
690
732
  return
733
+ for key in stale:
734
+ attrs.pop(key, None)
691
735
  attrs[str(port)] = updated
692
736
  with open(path, "w") as f:
693
737
  json.dump(settings, f, indent=2)