arrayview 0.36.0__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.36.0 → arrayview-0.37.1}/PKG-INFO +1 -1
  2. {arrayview-0.36.0 → arrayview-0.37.1}/pyproject.toml +1 -1
  3. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_io.py +84 -9
  4. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_launcher.py +33 -30
  5. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_loading.py +2 -0
  6. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_session.py +43 -0
  7. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_vscode_extension.py +1 -1
  8. arrayview-0.37.1/src/arrayview/arrayview-opener.vsix +0 -0
  9. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_api.py +2 -2
  10. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_cli.py +1 -1
  11. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_doctor_commands.py +30 -13
  12. arrayview-0.36.0/src/arrayview/arrayview-opener.vsix +0 -0
  13. {arrayview-0.36.0 → arrayview-0.37.1}/.gitignore +0 -0
  14. {arrayview-0.36.0 → arrayview-0.37.1}/LICENSE +0 -0
  15. {arrayview-0.36.0 → arrayview-0.37.1}/README.md +0 -0
  16. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/ARCHITECTURE.md +0 -0
  17. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/__init__.py +0 -0
  18. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/__main__.py +0 -0
  19. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_analysis.py +0 -0
  20. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_app.py +0 -0
  21. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_codex_open.py +0 -0
  22. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_config.py +0 -0
  23. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_dicom.py +0 -0
  24. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_diff.py +0 -0
  25. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_icon.png +0 -0
  26. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_imaging.py +0 -0
  27. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_instance_registry.py +0 -0
  28. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_launch_plan.py +0 -0
  29. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_launch_trace.py +0 -0
  30. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_lifecycle.py +0 -0
  31. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_overlays.py +0 -0
  32. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_platform.py +0 -0
  33. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_render.py +0 -0
  34. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_analysis.py +0 -0
  35. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_drop.py +0 -0
  36. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_export.py +0 -0
  37. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_persistence.py +0 -0
  38. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_preferences.py +0 -0
  39. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_preload.py +0 -0
  40. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_query.py +0 -0
  41. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_rendering.py +0 -0
  42. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_segmentation.py +0 -0
  43. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_state.py +0 -0
  44. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_vectorfield.py +0 -0
  45. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_routes_websocket.py +0 -0
  46. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_segmentation.py +0 -0
  47. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_server.py +0 -0
  48. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_session_spec.py +0 -0
  49. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_shell.html +0 -0
  50. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_synthetic_mri.py +0 -0
  51. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_torch.py +0 -0
  52. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_vectorfield.py +0 -0
  53. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_viewer.html +0 -0
  54. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_vscode.py +0 -0
  55. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_vscode_browser.py +0 -0
  56. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/_vscode_signal.py +0 -0
  57. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/gsap.min.js +0 -0
  58. {arrayview-0.36.0 → arrayview-0.37.1}/src/arrayview/todo.md +0 -0
  59. {arrayview-0.36.0 → arrayview-0.37.1}/tests/capture_patient_loading.py +0 -0
  60. {arrayview-0.36.0 → arrayview-0.37.1}/tests/capture_v_animation.py +0 -0
  61. {arrayview-0.36.0 → arrayview-0.37.1}/tests/conftest.py +0 -0
  62. {arrayview-0.36.0 → arrayview-0.37.1}/tests/lifecycle_matrix.py +0 -0
  63. {arrayview-0.36.0 → arrayview-0.37.1}/tests/make_vectorfield_test_arrays.py +0 -0
  64. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_backend_shared.py +0 -0
  65. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_browser.py +0 -0
  66. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_codex_open.py +0 -0
  67. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_colorbar_hover_highlight.py +0 -0
  68. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_command_reachability.py +0 -0
  69. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_config.py +0 -0
  70. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_cross_mode_parametrized.py +0 -0
  71. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_dicom.py +0 -0
  72. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_display_opener_results.py +0 -0
  73. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_drop_import.py +0 -0
  74. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_instance_registry.py +0 -0
  75. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_interactions.py +0 -0
  76. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_invocation_contract_matrix.py +0 -0
  77. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_io.py +0 -0
  78. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_large_arrays.py +0 -0
  79. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_launch_blackbox.py +0 -0
  80. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_launch_diagnostics.py +0 -0
  81. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_launch_execution_ownership.py +0 -0
  82. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_launch_plan.py +0 -0
  83. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_launch_trace.py +0 -0
  84. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_lifecycle_contract.py +0 -0
  85. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_loading_server.py +0 -0
  86. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_mode_consistency.py +0 -0
  87. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_mode_matrix.py +0 -0
  88. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_mode_roundtrip.py +0 -0
  89. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_nifti_meta.py +0 -0
  90. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_nifti_series.py +0 -0
  91. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_preferences_ui.py +0 -0
  92. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_public_invocation_regressions.py +0 -0
  93. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_rgb_pixel_art.py +0 -0
  94. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_safe_server_stop.py +0 -0
  95. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_server_identity_status.py +0 -0
  96. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_session_spec.py +0 -0
  97. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_spawn_ownership.py +0 -0
  98. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_torch.py +0 -0
  99. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_value_formatting.py +0 -0
  100. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_view_component_integration.py +0 -0
  101. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_view_component_unit.py +0 -0
  102. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_view_handle_lifecycle.py +0 -0
  103. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_view_launch_execution.py +0 -0
  104. {arrayview-0.36.0 → arrayview-0.37.1}/tests/test_vscode_ack_protocol.py +0 -0
  105. {arrayview-0.36.0 → arrayview-0.37.1}/tests/ui_audit.py +0 -0
  106. {arrayview-0.36.0 → arrayview-0.37.1}/tests/v_anim_frames/.gitkeep +0 -0
  107. {arrayview-0.36.0 → 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.36.0
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.36.0"
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)
@@ -4307,6 +4307,7 @@ def _serve_daemon(
4307
4307
  f"[ArrayView] Warning: failed to load vector field {vfield_filepath}: {e}",
4308
4308
  flush=True,
4309
4309
  )
4310
+ _session_mod.upgrade_memmap_in_background(session)
4310
4311
  _session_mod.SESSIONS[session.sid] = session
4311
4312
  if dir_overlay_items is not None:
4312
4313
  for item, ov_sid in zip(dir_overlay_items, overlay_sids or []):
@@ -4815,9 +4816,29 @@ def _instance_public_dict(record) -> dict[str, object]:
4815
4816
  return value
4816
4817
 
4817
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
+
4818
4839
  def _handle_management_command(argv: list[str]) -> bool:
4819
4840
  """Handle dependency-light management subcommands before the file CLI."""
4820
- if not argv or argv[0] not in {"doctor", "instances", "stop"}:
4841
+ if not argv or argv[0] not in {"doctor", "instances", "stop", "kill"}:
4821
4842
  return False
4822
4843
  command = argv[0]
4823
4844
  parser = argparse.ArgumentParser(prog=f"arrayview {command}")
@@ -4843,22 +4864,10 @@ def _handle_management_command(argv: list[str]) -> bool:
4843
4864
  )
4844
4865
  return True
4845
4866
 
4846
- if command == "stop":
4847
- group = parser.add_mutually_exclusive_group(required=True)
4848
- group.add_argument("instance_id", nargs="?")
4849
- group.add_argument("--all", action="store_true")
4850
- args = parser.parse_args(argv[1:])
4851
- records = InstanceRegistry().discover(clean_stale=True)
4852
- selected = (
4853
- records
4854
- if args.all
4855
- else [record for record in records if record.instance_id == args.instance_id]
4856
- )
4857
- if not selected:
4858
- parser.error("no matching running ArrayView instance")
4859
- for record in selected:
4860
- message, _pid = _stop_verified_server(record.port)
4861
- 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()
4862
4871
  return True
4863
4872
 
4864
4873
  parser.add_argument("--json", action="store_true")
@@ -5002,7 +5011,7 @@ def arrayview():
5002
5011
  parser.add_argument(
5003
5012
  "--kill",
5004
5013
  action="store_true",
5005
- help="Kill the ArrayView server running on --port (default 8000) and exit",
5014
+ help="Deprecated: use `arrayview stop`",
5006
5015
  )
5007
5016
  parser.add_argument(
5008
5017
  "--overlay",
@@ -5350,15 +5359,9 @@ def arrayview():
5350
5359
  sys.exit(1)
5351
5360
  return
5352
5361
 
5353
- # -- --kill: stop the server on the given port --
5362
+ # -- --kill: deprecated alias for `arrayview stop` --
5354
5363
  if args.kill:
5355
- message, _pid = _stop_verified_server(args.port)
5356
- print(f"[ArrayView] {message}")
5357
-
5358
- from arrayview._vscode_signal import _cleanup_zombie_registrations
5359
- cleaned = _cleanup_zombie_registrations(verbose=True)
5360
- if cleaned:
5361
- print(f"[ArrayView] Cleaned {cleaned} zombie window registration(s)")
5364
+ _run_stop_command()
5362
5365
  return
5363
5366
 
5364
5367
  # -- --serve: start a persistent empty server and exit --
@@ -5403,7 +5406,7 @@ def arrayview():
5403
5406
  if serve_context.evidence.is_vscode_remote:
5404
5407
  print(
5405
5408
  f"[ArrayView] Port {args.port} was claimed while starting.\n"
5406
- f" Run 'arrayview --kill --port {args.port}' to free it, "
5409
+ " Run 'arrayview stop' to free it, "
5407
5410
  "or use --port to specify a different port.",
5408
5411
  flush=True,
5409
5412
  )
@@ -5675,7 +5678,7 @@ def arrayview():
5675
5678
  if launch_plan.failure is LaunchFailure.REMOTE_PORT_CONFLICT:
5676
5679
  print(
5677
5680
  f"[ArrayView] Port {args.port} is in use by another process.\n"
5678
- f" Run 'arrayview --kill --port {args.port}' to free it, "
5681
+ " Run 'arrayview stop' to free it, "
5679
5682
  f"or use --port to specify a different port.",
5680
5683
  flush=True,
5681
5684
  )
@@ -24,6 +24,7 @@ from arrayview._session import (
24
24
  SESSIONS,
25
25
  Session,
26
26
  file_signature,
27
+ upgrade_memmap_in_background,
27
28
  wait_for_session_ready,
28
29
  )
29
30
 
@@ -293,6 +294,7 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
293
294
  session.related_release_sids = [
294
295
  str(value) for value in body.get("related_sids", [])
295
296
  ]
297
+ upgrade_memmap_in_background(session)
296
298
  commit_pending_session(sid, session)
297
299
  except Exception as exc:
298
300
  # Keep the server alive, and record why this sid will never
@@ -458,6 +458,49 @@ def _startup_dims_for_data(data, shape=None) -> tuple[int, int] | None:
458
458
  return _viewer_start_dims_for_shape(target_shape)
459
459
 
460
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
+
461
504
  SESSIONS = {}
462
505
 
463
506
  COLORMAPS = [
@@ -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.87" # 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:
@@ -4093,7 +4093,7 @@ class TestCliOpenHelpers:
4093
4093
 
4094
4094
  message = str(excinfo.value)
4095
4095
  assert "older ArrayView process" in message
4096
- assert "arrayview --kill --port 8000" in message
4096
+ assert "arrayview stop" in message
4097
4097
 
4098
4098
  def test_handle_cli_existing_server_opens_registered_session(self, monkeypatch):
4099
4099
  import arrayview._launcher as launcher
@@ -4190,7 +4190,7 @@ class TestCliOpenHelpers:
4190
4190
  out = capsys.readouterr().out
4191
4191
  assert "existing ArrayView server on port 8000" in out
4192
4192
  assert "directory stacking" in out
4193
- assert "arrayview --kill --port 8000" in out
4193
+ assert "arrayview stop" in out
4194
4194
 
4195
4195
  def test_handle_cli_existing_server_reports_load_error_without_port_conflict(
4196
4196
  self, monkeypatch, tmp_path, capsys
@@ -304,7 +304,7 @@ def test_cli_vscode_terminal_requires_extension_readiness_ack(tmp_path):
304
304
  assert not _wait_for_ping(port, timeout=1.0)
305
305
  finally:
306
306
  subprocess.run(
307
- [sys.executable, "-m", "arrayview", "--kill", "--port", str(port)],
307
+ [sys.executable, "-m", "arrayview", "stop"],
308
308
  env=env,
309
309
  capture_output=True,
310
310
  text=True,
@@ -59,8 +59,9 @@ def test_instances_json_lists_public_identity(monkeypatch, capsys):
59
59
  assert "control_token" not in result["instances"][0]
60
60
 
61
61
 
62
- def test_stop_resolves_instance_to_recorded_port(monkeypatch, capsys):
63
- _Registry.records = [_record("known", 9001)]
62
+ @pytest.mark.parametrize("command", ["stop", "kill"])
63
+ def test_stop_kills_every_recorded_instance(monkeypatch, capsys, command):
64
+ _Registry.records = [_record("first", 9001), _record("second", 9002)]
64
65
  monkeypatch.setattr("arrayview._instance_registry.InstanceRegistry", _Registry)
65
66
  calls = []
66
67
  monkeypatch.setattr(
@@ -68,20 +69,36 @@ def test_stop_resolves_instance_to_recorded_port(monkeypatch, capsys):
68
69
  "_stop_verified_server",
69
70
  lambda port: (calls.append(port) or "stopped", 123),
70
71
  )
71
- output = _run(monkeypatch, capsys, "stop", "known")
72
- assert calls == [9001]
73
- assert "known: stopped" in output
72
+ output = _run(monkeypatch, capsys, command)
73
+ assert calls == [9001, 9002]
74
+ assert "first: stopped" in output
75
+ assert "second: stopped" in output
74
76
 
75
77
 
76
- def test_stop_unknown_identity_never_invokes_port_stop(monkeypatch, capsys):
77
- _Registry.records = [_record("known", 9001)]
78
+ def test_stop_without_instances_is_not_an_error(monkeypatch, capsys):
79
+ _Registry.records = []
78
80
  monkeypatch.setattr("arrayview._instance_registry.InstanceRegistry", _Registry)
79
- monkeypatch.setattr(_launcher, "_stop_verified_server", lambda _port: pytest.fail("must not stop"))
80
- monkeypatch.setattr(sys, "argv", ["arrayview", "stop", "unknown"])
81
- with pytest.raises(SystemExit):
82
- _launcher.arrayview()
81
+ monkeypatch.setattr(
82
+ _launcher, "_stop_verified_server", lambda _port: pytest.fail("must not stop")
83
+ )
84
+ assert "No running ArrayView instances" in _run(monkeypatch, capsys, "stop")
83
85
 
84
86
 
85
- def test_legacy_diagnose_and_kill_still_bypass_subcommands(monkeypatch):
86
- assert not _launcher._handle_management_command(["--diagnose"])
87
+ def test_legacy_kill_flag_stops_every_instance(monkeypatch, capsys):
88
+ _Registry.records = [_record("first", 9001)]
89
+ monkeypatch.setattr("arrayview._instance_registry.InstanceRegistry", _Registry)
90
+ calls = []
91
+ monkeypatch.setattr(
92
+ _launcher,
93
+ "_stop_verified_server",
94
+ lambda port: (calls.append(port) or "stopped", 123),
95
+ )
96
+ # --kill is a flag on the file CLI, not a subcommand, so it bypasses
97
+ # _handle_management_command and lands in the launch parser.
87
98
  assert not _launcher._handle_management_command(["--kill"])
99
+ _run(monkeypatch, capsys, "--kill")
100
+ assert calls == [9001]
101
+
102
+
103
+ def test_legacy_diagnose_still_bypasses_subcommands(monkeypatch):
104
+ assert not _launcher._handle_management_command(["--diagnose"])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes