arrayview 0.36.0__tar.gz → 0.38.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.38.1}/PKG-INFO +1 -1
  2. {arrayview-0.36.0 → arrayview-0.38.1}/pyproject.toml +1 -2
  3. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_io.py +84 -9
  4. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_launch_plan.py +0 -1
  5. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_launcher.py +33 -30
  6. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_platform.py +51 -2
  7. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_loading.py +2 -0
  8. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_session.py +43 -0
  9. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_vscode_browser.py +6 -0
  10. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_vscode_extension.py +31 -15
  11. arrayview-0.38.1/src/arrayview/arrayview-opener.vsix +0 -0
  12. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_api.py +132 -2
  13. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_cli.py +1 -1
  14. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_doctor_commands.py +30 -13
  15. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_invocation_contract_matrix.py +0 -4
  16. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_launch_plan.py +0 -1
  17. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_lifecycle_contract.py +14 -0
  18. arrayview-0.36.0/src/arrayview/_codex_open.py +0 -137
  19. arrayview-0.36.0/src/arrayview/arrayview-opener.vsix +0 -0
  20. arrayview-0.36.0/tests/test_codex_open.py +0 -160
  21. {arrayview-0.36.0 → arrayview-0.38.1}/.gitignore +0 -0
  22. {arrayview-0.36.0 → arrayview-0.38.1}/LICENSE +0 -0
  23. {arrayview-0.36.0 → arrayview-0.38.1}/README.md +0 -0
  24. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/ARCHITECTURE.md +0 -0
  25. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/__init__.py +0 -0
  26. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/__main__.py +0 -0
  27. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_analysis.py +0 -0
  28. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_app.py +0 -0
  29. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_config.py +0 -0
  30. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_dicom.py +0 -0
  31. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_diff.py +0 -0
  32. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_icon.png +0 -0
  33. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_imaging.py +0 -0
  34. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_instance_registry.py +0 -0
  35. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_launch_trace.py +0 -0
  36. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_lifecycle.py +0 -0
  37. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_overlays.py +0 -0
  38. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_render.py +0 -0
  39. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_analysis.py +0 -0
  40. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_drop.py +0 -0
  41. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_export.py +0 -0
  42. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_persistence.py +0 -0
  43. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_preferences.py +0 -0
  44. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_preload.py +0 -0
  45. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_query.py +0 -0
  46. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_rendering.py +0 -0
  47. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_segmentation.py +0 -0
  48. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_state.py +0 -0
  49. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_vectorfield.py +0 -0
  50. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_routes_websocket.py +0 -0
  51. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_segmentation.py +0 -0
  52. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_server.py +0 -0
  53. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_session_spec.py +0 -0
  54. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_shell.html +0 -0
  55. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_synthetic_mri.py +0 -0
  56. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_torch.py +0 -0
  57. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_vectorfield.py +0 -0
  58. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_viewer.html +0 -0
  59. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_vscode.py +0 -0
  60. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/_vscode_signal.py +0 -0
  61. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/gsap.min.js +0 -0
  62. {arrayview-0.36.0 → arrayview-0.38.1}/src/arrayview/todo.md +0 -0
  63. {arrayview-0.36.0 → arrayview-0.38.1}/tests/capture_patient_loading.py +0 -0
  64. {arrayview-0.36.0 → arrayview-0.38.1}/tests/capture_v_animation.py +0 -0
  65. {arrayview-0.36.0 → arrayview-0.38.1}/tests/conftest.py +0 -0
  66. {arrayview-0.36.0 → arrayview-0.38.1}/tests/lifecycle_matrix.py +0 -0
  67. {arrayview-0.36.0 → arrayview-0.38.1}/tests/make_vectorfield_test_arrays.py +0 -0
  68. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_backend_shared.py +0 -0
  69. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_browser.py +0 -0
  70. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_colorbar_hover_highlight.py +0 -0
  71. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_command_reachability.py +0 -0
  72. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_config.py +0 -0
  73. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_cross_mode_parametrized.py +0 -0
  74. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_dicom.py +0 -0
  75. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_display_opener_results.py +0 -0
  76. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_drop_import.py +0 -0
  77. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_instance_registry.py +0 -0
  78. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_interactions.py +0 -0
  79. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_io.py +0 -0
  80. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_large_arrays.py +0 -0
  81. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_launch_blackbox.py +0 -0
  82. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_launch_diagnostics.py +0 -0
  83. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_launch_execution_ownership.py +0 -0
  84. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_launch_trace.py +0 -0
  85. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_loading_server.py +0 -0
  86. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_mode_consistency.py +0 -0
  87. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_mode_matrix.py +0 -0
  88. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_mode_roundtrip.py +0 -0
  89. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_nifti_meta.py +0 -0
  90. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_nifti_series.py +0 -0
  91. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_preferences_ui.py +0 -0
  92. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_public_invocation_regressions.py +0 -0
  93. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_rgb_pixel_art.py +0 -0
  94. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_safe_server_stop.py +0 -0
  95. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_server_identity_status.py +0 -0
  96. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_session_spec.py +0 -0
  97. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_spawn_ownership.py +0 -0
  98. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_torch.py +0 -0
  99. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_value_formatting.py +0 -0
  100. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_view_component_integration.py +0 -0
  101. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_view_component_unit.py +0 -0
  102. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_view_handle_lifecycle.py +0 -0
  103. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_view_launch_execution.py +0 -0
  104. {arrayview-0.36.0 → arrayview-0.38.1}/tests/test_vscode_ack_protocol.py +0 -0
  105. {arrayview-0.36.0 → arrayview-0.38.1}/tests/ui_audit.py +0 -0
  106. {arrayview-0.36.0 → arrayview-0.38.1}/tests/v_anim_frames/.gitkeep +0 -0
  107. {arrayview-0.36.0 → arrayview-0.38.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.38.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.38.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"
@@ -43,7 +43,6 @@ dependencies = [
43
43
 
44
44
  [project.scripts]
45
45
  arrayview = "arrayview._launcher:arrayview"
46
- arrayview-codex = "arrayview._codex_open:main"
47
46
 
48
47
  [tool.hatch.build.targets.wheel]
49
48
  packages = ["src/arrayview"]
@@ -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:
@@ -41,7 +41,6 @@ class Invocation(_StrEnum):
41
41
  JULIA = "julia"
42
42
  MATLAB = "matlab"
43
43
  VSCODE_EXPLORER = "vscode_explorer"
44
- CODEX = "codex"
45
44
 
46
45
 
47
46
  class Environment(_StrEnum):
@@ -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
  )
@@ -6,6 +6,7 @@ import importlib.util
6
6
  import hashlib
7
7
  import json
8
8
  import os
9
+ from collections.abc import Callable
9
10
  import subprocess
10
11
  import sys
11
12
 
@@ -450,6 +451,42 @@ def _process_is_alive(pid: object) -> bool:
450
451
  return False
451
452
 
452
453
 
454
+ def _superseding_window_registration(
455
+ claimed_ids: list[str],
456
+ signal_dir: str,
457
+ read: Callable[[str], tuple[str, dict] | None],
458
+ ) -> tuple[str, dict] | None:
459
+ """Return the live window that replaced one of ``claimed_ids``, if unique.
460
+
461
+ Each registration lists the window ids it took over from, so a terminal left
462
+ holding a pre-reload id resolves to its own window rather than to a sibling
463
+ that merely shares a server root. Two live windows claiming one id would be a
464
+ contradiction, so an ambiguous answer is refused instead of guessed.
465
+ """
466
+ if not claimed_ids:
467
+ return None
468
+ wanted = set(claimed_ids)
469
+ try:
470
+ filenames = os.listdir(signal_dir)
471
+ except OSError:
472
+ return None
473
+
474
+ matches: list[tuple[str, dict]] = []
475
+ for filename in filenames:
476
+ if not filename.startswith("window-") or not filename.endswith(".json"):
477
+ continue
478
+ candidate = read(filename[len("window-") : -len(".json")])
479
+ if candidate is None:
480
+ continue
481
+ superseded = candidate[1].get("supersedes")
482
+ if not isinstance(superseded, list):
483
+ continue
484
+ if wanted.intersection(item for item in superseded if isinstance(item, str)):
485
+ matches.append(candidate)
486
+
487
+ return matches[0] if len(matches) == 1 else None
488
+
489
+
453
490
  def _exact_vscode_window_registration(
454
491
  ipc: str | None = None,
455
492
  ) -> tuple[str, dict] | None:
@@ -461,6 +498,11 @@ def _exact_vscode_window_registration(
461
498
  remote registration is unsafe. When direct window evidence is unavailable,
462
499
  match the live extension-host executable to the exact server root named by
463
500
  the terminal's NLS configuration, and only accept a unique match.
501
+
502
+ Reloading a window rotates its IPC socket and re-injects ``ARRAYVIEW_WINDOW_ID``
503
+ into new terminals only, so a terminal that predates the reload keeps naming a
504
+ window id that no longer registers. The extension records the ids it replaced,
505
+ which turns that stale name back into an exact match on the window that owns it.
464
506
  """
465
507
  signal_dir = os.path.expanduser("~/.arrayview")
466
508
 
@@ -479,16 +521,23 @@ def _exact_vscode_window_registration(
479
521
  return None
480
522
  return window_id, registration
481
523
 
524
+ claimed_ids: list[str] = []
482
525
  registration = None
483
526
  if ipc:
484
527
  window_id = hashlib.sha256(ipc.encode()).hexdigest()[:16]
485
528
  if reg := _read(window_id):
486
529
  return reg
530
+ claimed_ids.append(window_id)
487
531
  registration = reg
488
532
  if registration is None:
489
533
  window_id = os.environ.get("ARRAYVIEW_WINDOW_ID")
490
- if window_id and (reg := _read(window_id)):
491
- return reg
534
+ if window_id:
535
+ if reg := _read(window_id):
536
+ return reg
537
+ claimed_ids.append(window_id)
538
+
539
+ if reg := _superseding_window_registration(claimed_ids, signal_dir, _read):
540
+ return reg
492
541
 
493
542
  exact_cli = _current_vscode_remote_cli()
494
543
  if exact_cli is None:
@@ -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 = [
@@ -275,6 +275,12 @@ def _open_browser(
275
275
  "ArrayView could not safely install its VS Code opener in "
276
276
  "this exact remote window; no viewer request was sent."
277
277
  )
278
+ elif _extension_state._VSCODE_EXT_NO_LIVE_WINDOW:
279
+ detail = (
280
+ "This terminal was opened before its VS Code window reloaded, "
281
+ "so it still points at a window that no longer exists. Open a "
282
+ "new terminal in the target window and retry."
283
+ )
278
284
  elif _extension_state._VSCODE_EXT_RELOAD_REQUIRED:
279
285
  detail = (
280
286
  "ArrayView's VS Code opener was installed; reload this exact "
@@ -54,7 +54,8 @@ _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_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
58
+ _VSCODE_EXT_VERSION = "0.14.89" # current bundled extension version
58
59
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
59
60
 
60
61
  def _bundled_vscode_vsix_version(vsix_path: str) -> str | None:
@@ -471,9 +472,11 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
471
472
  """
472
473
  global _VSCODE_EXT_INSTALLED, _VSCODE_EXT_FRESH_INSTALL
473
474
  global _VSCODE_EXT_RELOAD_REQUIRED, _VSCODE_EXT_INSTALL_FAILED
475
+ global _VSCODE_EXT_NO_LIVE_WINDOW
474
476
  _VSCODE_EXT_FRESH_INSTALL = False
475
477
  _VSCODE_EXT_RELOAD_REQUIRED = False
476
478
  _VSCODE_EXT_INSTALL_FAILED = False
479
+ _VSCODE_EXT_NO_LIVE_WINDOW = False
477
480
 
478
481
  vsix_path = str(_pkg_files("arrayview").joinpath("arrayview-opener.vsix"))
479
482
  if not os.path.isfile(vsix_path):
@@ -496,6 +499,31 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
496
499
  and active_registration.get("extensionVersion") == active_version
497
500
  else None
498
501
  )
502
+ def _reject_unmatched_host(installed_version: str) -> bool:
503
+ """Record why the live host was rejected, so callers can advise correctly.
504
+
505
+ A version disagreement is fixed by reloading; an unidentifiable window is
506
+ not. Conflating them tells the user to reload when the reload is what
507
+ stranded their terminal in the first place.
508
+ """
509
+ global _VSCODE_EXT_RELOAD_REQUIRED, _VSCODE_EXT_NO_LIVE_WINDOW
510
+ if active_version is None:
511
+ _VSCODE_EXT_NO_LIVE_WINDOW = True
512
+ _vprint(
513
+ f"[ArrayView] opener v{installed_version} is installed, but no live "
514
+ "VS Code window claims this terminal; open a new terminal in the "
515
+ "target window",
516
+ flush=True,
517
+ )
518
+ return False
519
+ _VSCODE_EXT_RELOAD_REQUIRED = True
520
+ _vprint(
521
+ f"[ArrayView] opener v{installed_version} is installed, but this VS Code "
522
+ "window is still running an older extension host; reload this window",
523
+ flush=True,
524
+ )
525
+ return False
526
+
499
527
  if _extension_on_disk(ext_version, vsix_path, remote=is_remote):
500
528
  active_matches = (
501
529
  active_version == ext_version
@@ -503,13 +531,7 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
503
531
  else active_version in (None, ext_version)
504
532
  )
505
533
  if not active_matches:
506
- _VSCODE_EXT_RELOAD_REQUIRED = True
507
- _vprint(
508
- f"[ArrayView] opener v{ext_version} is installed, but this VS Code "
509
- "window is still running an older extension host; reload this window",
510
- flush=True,
511
- )
512
- return False
534
+ return _reject_unmatched_host(ext_version)
513
535
  if not is_remote:
514
536
  _remove_old_extension_versions(ext_version, remote=False)
515
537
  _VSCODE_EXT_INSTALLED = True
@@ -527,13 +549,7 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
527
549
  else active_version in (None, newer_version)
528
550
  )
529
551
  if not active_matches:
530
- _VSCODE_EXT_RELOAD_REQUIRED = True
531
- _vprint(
532
- f"[ArrayView] newer opener v{newer_version} is installed, but this "
533
- "VS Code window is still running an older extension host; reload this window",
534
- flush=True,
535
- )
536
- return False
552
+ return _reject_unmatched_host(newer_version)
537
553
  _VSCODE_EXT_INSTALLED = True
538
554
  _vprint(
539
555
  f"[ArrayView] newer extension v{newer_version} is installed — keeping it",