arrayview 0.37.1__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.37.1 → arrayview-0.38.1}/PKG-INFO +1 -1
  2. {arrayview-0.37.1 → arrayview-0.38.1}/pyproject.toml +1 -2
  3. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_launch_plan.py +0 -1
  4. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_platform.py +51 -2
  5. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_vscode_browser.py +6 -0
  6. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_vscode_extension.py +31 -15
  7. arrayview-0.38.1/src/arrayview/arrayview-opener.vsix +0 -0
  8. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_api.py +130 -0
  9. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_invocation_contract_matrix.py +0 -4
  10. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_launch_plan.py +0 -1
  11. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_lifecycle_contract.py +14 -0
  12. arrayview-0.37.1/src/arrayview/_codex_open.py +0 -137
  13. arrayview-0.37.1/src/arrayview/arrayview-opener.vsix +0 -0
  14. arrayview-0.37.1/tests/test_codex_open.py +0 -160
  15. {arrayview-0.37.1 → arrayview-0.38.1}/.gitignore +0 -0
  16. {arrayview-0.37.1 → arrayview-0.38.1}/LICENSE +0 -0
  17. {arrayview-0.37.1 → arrayview-0.38.1}/README.md +0 -0
  18. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/ARCHITECTURE.md +0 -0
  19. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/__init__.py +0 -0
  20. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/__main__.py +0 -0
  21. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_analysis.py +0 -0
  22. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_app.py +0 -0
  23. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_config.py +0 -0
  24. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_dicom.py +0 -0
  25. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_diff.py +0 -0
  26. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_icon.png +0 -0
  27. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_imaging.py +0 -0
  28. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_instance_registry.py +0 -0
  29. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_io.py +0 -0
  30. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_launch_trace.py +0 -0
  31. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_launcher.py +0 -0
  32. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_lifecycle.py +0 -0
  33. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_overlays.py +0 -0
  34. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_render.py +0 -0
  35. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_analysis.py +0 -0
  36. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_drop.py +0 -0
  37. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_export.py +0 -0
  38. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_loading.py +0 -0
  39. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_persistence.py +0 -0
  40. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_preferences.py +0 -0
  41. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_preload.py +0 -0
  42. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_query.py +0 -0
  43. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_rendering.py +0 -0
  44. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_segmentation.py +0 -0
  45. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_state.py +0 -0
  46. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_vectorfield.py +0 -0
  47. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_routes_websocket.py +0 -0
  48. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_segmentation.py +0 -0
  49. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_server.py +0 -0
  50. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_session.py +0 -0
  51. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_session_spec.py +0 -0
  52. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_shell.html +0 -0
  53. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_synthetic_mri.py +0 -0
  54. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_torch.py +0 -0
  55. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_vectorfield.py +0 -0
  56. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_viewer.html +0 -0
  57. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_vscode.py +0 -0
  58. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/_vscode_signal.py +0 -0
  59. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/gsap.min.js +0 -0
  60. {arrayview-0.37.1 → arrayview-0.38.1}/src/arrayview/todo.md +0 -0
  61. {arrayview-0.37.1 → arrayview-0.38.1}/tests/capture_patient_loading.py +0 -0
  62. {arrayview-0.37.1 → arrayview-0.38.1}/tests/capture_v_animation.py +0 -0
  63. {arrayview-0.37.1 → arrayview-0.38.1}/tests/conftest.py +0 -0
  64. {arrayview-0.37.1 → arrayview-0.38.1}/tests/lifecycle_matrix.py +0 -0
  65. {arrayview-0.37.1 → arrayview-0.38.1}/tests/make_vectorfield_test_arrays.py +0 -0
  66. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_backend_shared.py +0 -0
  67. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_browser.py +0 -0
  68. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_cli.py +0 -0
  69. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_colorbar_hover_highlight.py +0 -0
  70. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_command_reachability.py +0 -0
  71. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_config.py +0 -0
  72. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_cross_mode_parametrized.py +0 -0
  73. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_dicom.py +0 -0
  74. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_display_opener_results.py +0 -0
  75. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_doctor_commands.py +0 -0
  76. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_drop_import.py +0 -0
  77. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_instance_registry.py +0 -0
  78. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_interactions.py +0 -0
  79. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_io.py +0 -0
  80. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_large_arrays.py +0 -0
  81. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_launch_blackbox.py +0 -0
  82. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_launch_diagnostics.py +0 -0
  83. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_launch_execution_ownership.py +0 -0
  84. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_launch_trace.py +0 -0
  85. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_loading_server.py +0 -0
  86. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_mode_consistency.py +0 -0
  87. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_mode_matrix.py +0 -0
  88. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_mode_roundtrip.py +0 -0
  89. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_nifti_meta.py +0 -0
  90. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_nifti_series.py +0 -0
  91. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_preferences_ui.py +0 -0
  92. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_public_invocation_regressions.py +0 -0
  93. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_rgb_pixel_art.py +0 -0
  94. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_safe_server_stop.py +0 -0
  95. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_server_identity_status.py +0 -0
  96. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_session_spec.py +0 -0
  97. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_spawn_ownership.py +0 -0
  98. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_torch.py +0 -0
  99. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_value_formatting.py +0 -0
  100. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_view_component_integration.py +0 -0
  101. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_view_component_unit.py +0 -0
  102. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_view_handle_lifecycle.py +0 -0
  103. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_view_launch_execution.py +0 -0
  104. {arrayview-0.37.1 → arrayview-0.38.1}/tests/test_vscode_ack_protocol.py +0 -0
  105. {arrayview-0.37.1 → arrayview-0.38.1}/tests/ui_audit.py +0 -0
  106. {arrayview-0.37.1 → arrayview-0.38.1}/tests/v_anim_frames/.gitkeep +0 -0
  107. {arrayview-0.37.1 → 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.37.1
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.37.1"
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"]
@@ -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):
@@ -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:
@@ -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.88" # 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",
@@ -5667,6 +5667,136 @@ class TestPortAndTunnelHelpers:
5667
5667
  is None
5668
5668
  )
5669
5669
 
5670
+ def _write_reload_survivor_home(self, monkeypatch, tmp_path):
5671
+ """Home whose only live window replaced the one this terminal remembers."""
5672
+ import hashlib
5673
+
5674
+ home = tmp_path / "home"
5675
+ signal_dir = home / ".arrayview"
5676
+ signal_dir.mkdir(parents=True)
5677
+ monkeypatch.setenv("HOME", str(home))
5678
+ monkeypatch.delenv("ARRAYVIEW_WINDOW_ID", raising=False)
5679
+ stale_ipc = "/run/user/1/vscode-ipc-before-reload.sock"
5680
+ return signal_dir, stale_ipc, hashlib.sha256(stale_ipc.encode()).hexdigest()[:16]
5681
+
5682
+ def test_terminal_stranded_by_window_reload_resolves_via_supersedes(
5683
+ self, monkeypatch, tmp_path
5684
+ ):
5685
+ import os
5686
+ import arrayview._platform as platform
5687
+
5688
+ signal_dir, stale_ipc, stale_id = self._write_reload_survivor_home(
5689
+ monkeypatch, tmp_path
5690
+ )
5691
+ (signal_dir / "window-after-reload.json").write_text(
5692
+ json.dumps(
5693
+ {
5694
+ "pid": os.getpid(),
5695
+ "hookTag": "after-reload",
5696
+ "remoteName": "tunnel",
5697
+ "extensionVersion": "0.14.89",
5698
+ "supersedes": [stale_id],
5699
+ }
5700
+ )
5701
+ )
5702
+
5703
+ resolved = platform._exact_vscode_window_registration(stale_ipc)
5704
+
5705
+ assert resolved is not None
5706
+ assert resolved[0] == "after-reload"
5707
+ assert resolved[1]["extensionVersion"] == "0.14.89"
5708
+
5709
+ def test_stranded_terminal_resolves_stale_injected_window_id(
5710
+ self, monkeypatch, tmp_path
5711
+ ):
5712
+ import os
5713
+ import arrayview._platform as platform
5714
+
5715
+ home = tmp_path / "home"
5716
+ signal_dir = home / ".arrayview"
5717
+ signal_dir.mkdir(parents=True)
5718
+ monkeypatch.setenv("HOME", str(home))
5719
+ monkeypatch.setenv("ARRAYVIEW_WINDOW_ID", "pre-reload-id")
5720
+ (signal_dir / "window-after-reload.json").write_text(
5721
+ json.dumps(
5722
+ {
5723
+ "pid": os.getpid(),
5724
+ "remoteName": "tunnel",
5725
+ "supersedes": ["older-still", "pre-reload-id"],
5726
+ }
5727
+ )
5728
+ )
5729
+
5730
+ resolved = platform._exact_vscode_window_registration(None)
5731
+
5732
+ assert resolved is not None and resolved[0] == "after-reload"
5733
+
5734
+ def test_stranded_terminal_never_borrows_an_unrelated_window(
5735
+ self, monkeypatch, tmp_path
5736
+ ):
5737
+ """A sibling window that never owned this terminal must not claim it."""
5738
+ import os
5739
+ import arrayview._platform as platform
5740
+
5741
+ signal_dir, stale_ipc, _stale_id = self._write_reload_survivor_home(
5742
+ monkeypatch, tmp_path
5743
+ )
5744
+ (signal_dir / "window-unrelated.json").write_text(
5745
+ json.dumps(
5746
+ {
5747
+ "pid": os.getpid(),
5748
+ "remoteName": "tunnel",
5749
+ "supersedes": ["some-other-window"],
5750
+ }
5751
+ )
5752
+ )
5753
+ monkeypatch.setattr(platform, "_current_vscode_remote_cli", lambda: None)
5754
+
5755
+ assert platform._exact_vscode_window_registration(stale_ipc) is None
5756
+
5757
+ def test_contested_superseded_id_is_refused_rather_than_guessed(
5758
+ self, monkeypatch, tmp_path
5759
+ ):
5760
+ import os
5761
+ import arrayview._platform as platform
5762
+
5763
+ signal_dir, stale_ipc, stale_id = self._write_reload_survivor_home(
5764
+ monkeypatch, tmp_path
5765
+ )
5766
+ for name in ("window-first.json", "window-second.json"):
5767
+ (signal_dir / name).write_text(
5768
+ json.dumps(
5769
+ {
5770
+ "pid": os.getpid(),
5771
+ "remoteName": "tunnel",
5772
+ "supersedes": [stale_id],
5773
+ }
5774
+ )
5775
+ )
5776
+ monkeypatch.setattr(platform, "_current_vscode_remote_cli", lambda: None)
5777
+
5778
+ assert platform._exact_vscode_window_registration(stale_ipc) is None
5779
+
5780
+ def test_dead_superseding_window_does_not_resolve(self, monkeypatch, tmp_path):
5781
+ """Lineage is not enough — the claiming host must still be running."""
5782
+ import arrayview._platform as platform
5783
+
5784
+ signal_dir, stale_ipc, stale_id = self._write_reload_survivor_home(
5785
+ monkeypatch, tmp_path
5786
+ )
5787
+ (signal_dir / "window-dead.json").write_text(
5788
+ json.dumps(
5789
+ {
5790
+ "pid": 2**31 - 1,
5791
+ "remoteName": "tunnel",
5792
+ "supersedes": [stale_id],
5793
+ }
5794
+ )
5795
+ )
5796
+ monkeypatch.setattr(platform, "_current_vscode_remote_cli", lambda: None)
5797
+
5798
+ assert platform._exact_vscode_window_registration(stale_ipc) is None
5799
+
5670
5800
  def test_code_cli_selection_uses_captured_placement(self, monkeypatch):
5671
5801
  import glob
5672
5802
  import shutil
@@ -73,10 +73,6 @@ CONTRACT_CASES = (
73
73
  "python_plain_ssh", Invocation.PYTHON, Environment.SSH,
74
74
  ServerOwner.IN_PROCESS, Registration.IN_PROCESS_SESSION, Display.BROWSER,
75
75
  ),
76
- ContractCase(
77
- "codex_local", Invocation.CODEX, Environment.TERMINAL,
78
- ServerOwner.IN_PROCESS, Registration.IN_PROCESS_SESSION, Display.BROWSER,
79
- ),
80
76
  )
81
77
 
82
78
 
@@ -95,7 +95,6 @@ def test_launch_data_enums_match_refactor_contract():
95
95
  "julia",
96
96
  "matlab",
97
97
  "vscode_explorer",
98
- "codex",
99
98
  }
100
99
  assert {item.value for item in Environment} == {
101
100
  "terminal",
@@ -1701,6 +1701,20 @@ def test_vscode_open_ack_requires_requested_session_metadata():
1701
1701
  assert "Viewer session did not become ready" in source
1702
1702
 
1703
1703
 
1704
+ def test_vscode_registration_publishes_superseded_window_ids():
1705
+ """Terminals older than the last reload are only reachable via this chain."""
1706
+ source = (Path(__file__).resolve().parents[1] / "vscode-extension" / "extension.js").read_text()
1707
+
1708
+ # The previous id must be read before replace() overwrites the collection.
1709
+ assert source.index("_readEnvCollection('ARRAYVIEW_WINDOW_ID')") < source.index(
1710
+ "envCollection.replace('ARRAYVIEW_WINDOW_ID', windowId)"
1711
+ )
1712
+ assert "_readEnvCollection('ARRAYVIEW_WINDOW_CHAIN')" in source
1713
+ assert "envCollection.replace('ARRAYVIEW_WINDOW_CHAIN', supersededIds.join(','))" in source
1714
+ assert "supersedes: supersededIds," in source
1715
+ assert "supersedes.slice(0, MAX_SUPERSEDED_WINDOW_IDS)" in source
1716
+
1717
+
1704
1718
  def test_vscode_tunnel_resolution_reuses_verified_routes_and_retries_fresh():
1705
1719
  source = (Path(__file__).resolve().parents[1] / "vscode-extension" / "extension.js").read_text()
1706
1720
 
@@ -1,137 +0,0 @@
1
- """Codex-app helper for safely loading one file into ArrayView.
2
-
3
- Usage:
4
- uv run arrayview-codex path/to/file.nii
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import argparse
10
- import os
11
- import subprocess
12
- import sys
13
- import uuid
14
-
15
- from arrayview._instance_registry import InstanceRegistry
16
- from arrayview._launch_plan import (
17
- Invocation,
18
- LaunchIntent,
19
- Registration,
20
- create_launch_context,
21
- )
22
- from arrayview._launcher import (
23
- _find_server_port,
24
- _load_session_from_filepath,
25
- _port_in_use,
26
- _revalidate_launch_server,
27
- _server_alive,
28
- _server_pid,
29
- _wait_for_port,
30
- )
31
-
32
-
33
- def _start_loaded_server(
34
- filepath: str, port: int, sid: str, name: str
35
- ) -> subprocess.Popen[bytes]:
36
- script = (
37
- "from arrayview._launcher import _serve_daemon; "
38
- f"_serve_daemon({filepath!r}, {port}, {sid!r}, name={name!r}, persist=True)"
39
- )
40
- proc = subprocess.Popen(
41
- [sys.executable, "-c", script],
42
- stdin=subprocess.DEVNULL,
43
- stdout=subprocess.DEVNULL,
44
- stderr=subprocess.DEVNULL,
45
- start_new_session=True,
46
- )
47
- if not _wait_for_port(port, timeout=15.0, tcp_only=True):
48
- if proc.poll() is None:
49
- proc.terminate()
50
- raise RuntimeError(f"ArrayView server failed to start on port {port}")
51
- if not _server_alive(port, timeout=2.0):
52
- if proc.poll() is None:
53
- proc.terminate()
54
- raise RuntimeError(f"ArrayView server on port {port} did not become healthy")
55
- actual_pid = _server_pid(port)
56
- if actual_pid != proc.pid:
57
- if proc.poll() is None:
58
- proc.terminate()
59
- raise RuntimeError(
60
- f"Port {port} was claimed by another process while ArrayView was starting"
61
- )
62
- return proc
63
-
64
-
65
- def _open_codex_file(filepath: str, requested_port: int) -> str:
66
- context = create_launch_context(
67
- LaunchIntent(
68
- Invocation.CODEX,
69
- requested_port,
70
- requested_window="browser",
71
- persistent=True,
72
- )
73
- )
74
- if not context.plan.ok:
75
- raise ValueError(f"Invalid launch request: {context.plan.failure.value}")
76
-
77
- name = os.path.basename(filepath)
78
- expected_server_id = None
79
- with InstanceRegistry().startup_lock(timeout=20.0):
80
- port = context.plan.effective_port
81
- already_running = context.plan.registration is Registration.HTTP_LOAD
82
- if already_running:
83
- port = _revalidate_launch_server(context, port)
84
- expected_server_id = context.evidence.server.server_instance_id
85
- else:
86
- if _port_in_use(port):
87
- port, appeared_running = _find_server_port(port + 1)
88
- if appeared_running or _port_in_use(port):
89
- raise RuntimeError("Could not find a free port for ArrayView")
90
- if not 1 <= port <= 65535 or _port_in_use(port):
91
- raise RuntimeError("Could not find a free port for ArrayView")
92
- sid = uuid.uuid4().hex
93
- _start_loaded_server(filepath, port, sid, name)
94
-
95
- if already_running:
96
- result = _load_session_from_filepath(
97
- port,
98
- filepath,
99
- name,
100
- expected_server_id=expected_server_id,
101
- release_on_disconnect=True,
102
- )
103
- if "error" in result:
104
- raise RuntimeError(f"ArrayView server rejected the file: {result['error']}")
105
- sid = str(result["sid"])
106
-
107
- return f"http://localhost:{port}/?sid={sid}"
108
-
109
-
110
- def main() -> int:
111
- parser = argparse.ArgumentParser(
112
- prog="arrayview-codex",
113
- description=(
114
- "Load one file into a compatible ArrayView server, starting one on "
115
- "a free port when needed, and print the Codex app browser URL."
116
- ),
117
- )
118
- parser.add_argument("file", help="Array file to load")
119
- parser.add_argument("--port", type=int, default=8000, help="Preferred port")
120
- args = parser.parse_args()
121
-
122
- filepath = os.path.abspath(args.file)
123
- if not os.path.isfile(filepath):
124
- print(f"Error: file not found: {filepath}", file=sys.stderr)
125
- return 1
126
-
127
- try:
128
- url = _open_codex_file(filepath, args.port)
129
- except (OSError, RuntimeError, TimeoutError, ValueError) as exc:
130
- print(f"Error: {exc}", file=sys.stderr)
131
- return 1
132
- print(url)
133
- return 0
134
-
135
-
136
- if __name__ == "__main__":
137
- raise SystemExit(main())
@@ -1,160 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from contextlib import contextmanager
4
- from types import SimpleNamespace
5
-
6
- from arrayview._launch_plan import Registration
7
-
8
-
9
- def _context(port: int, *, existing: bool = False):
10
- return SimpleNamespace(
11
- plan=SimpleNamespace(
12
- ok=True,
13
- effective_port=port,
14
- failure=None,
15
- registration=(
16
- Registration.HTTP_LOAD
17
- if existing
18
- else Registration.DAEMON_STARTUP
19
- ),
20
- ),
21
- evidence=SimpleNamespace(
22
- server=SimpleNamespace(
23
- server_instance_id="existing-server" if existing else None
24
- )
25
- )
26
- )
27
-
28
-
29
- class _Registry:
30
- def __init__(self, events: list[str]):
31
- self.events = events
32
-
33
- @contextmanager
34
- def startup_lock(self, *, timeout: float):
35
- self.events.append(f"lock:{timeout}")
36
- try:
37
- yield
38
- finally:
39
- self.events.append("unlock")
40
-
41
-
42
- def test_codex_reuses_compatible_server_without_killing_or_spawning(
43
- monkeypatch, tmp_path
44
- ):
45
- import arrayview._codex_open as codex
46
-
47
- filepath = tmp_path / "array.npy"
48
- filepath.write_bytes(b"data")
49
- events: list[str] = []
50
- intents = []
51
- monkeypatch.setattr(
52
- codex,
53
- "create_launch_context",
54
- lambda intent: intents.append(intent) or _context(8000, existing=True),
55
- )
56
- monkeypatch.setattr(codex, "InstanceRegistry", lambda: _Registry(events))
57
- monkeypatch.setattr(
58
- codex,
59
- "_revalidate_launch_server",
60
- lambda context, port: port,
61
- )
62
- monkeypatch.setattr(
63
- codex,
64
- "_load_session_from_filepath",
65
- lambda port, path, name, **kwargs: events.append(
66
- f"load:{port}:{name}:{kwargs['expected_server_id']}:"
67
- f"release={kwargs['release_on_disconnect']}"
68
- )
69
- or {"sid": "existing-sid"},
70
- )
71
- monkeypatch.setattr(
72
- codex,
73
- "_start_loaded_server",
74
- lambda *args: (_ for _ in ()).throw(AssertionError("must reuse the server")),
75
- )
76
- monkeypatch.setattr(
77
- codex.os,
78
- "kill",
79
- lambda *args: (_ for _ in ()).throw(AssertionError("must not kill a listener")),
80
- )
81
-
82
- url = codex._open_codex_file(str(filepath), 8000)
83
-
84
- assert url == "http://localhost:8000/?sid=existing-sid"
85
- assert events == [
86
- "lock:20.0",
87
- "unlock",
88
- "load:8000:array.npy:existing-server:release=True",
89
- ]
90
- assert intents[0].invocation.value == "codex"
91
- assert intents[0].requested_window == "browser"
92
- assert intents[0].persistent is True
93
-
94
-
95
- def test_codex_uses_free_port_and_starts_under_startup_lock(monkeypatch, tmp_path):
96
- import arrayview._codex_open as codex
97
-
98
- filepath = tmp_path / "array.npy"
99
- filepath.write_bytes(b"data")
100
- events: list[str] = []
101
- monkeypatch.setattr(codex, "create_launch_context", lambda intent: _context(8001))
102
- monkeypatch.setattr(codex, "InstanceRegistry", lambda: _Registry(events))
103
- monkeypatch.setattr(codex, "_port_in_use", lambda port: False)
104
-
105
- def start(path, port, sid, name):
106
- assert events == ["lock:20.0"]
107
- events.append(f"start:{port}:{name}")
108
- return SimpleNamespace(pid=123)
109
-
110
- monkeypatch.setattr(codex, "_start_loaded_server", start)
111
- monkeypatch.setattr(codex.uuid, "uuid4", lambda: SimpleNamespace(hex="new-sid"))
112
-
113
- url = codex._open_codex_file(str(filepath), 8000)
114
-
115
- assert url == "http://localhost:8001/?sid=new-sid"
116
- assert events == ["lock:20.0", "start:8001:array.npy", "unlock"]
117
-
118
-
119
- def test_spawned_server_must_match_child_pid(monkeypatch):
120
- import arrayview._codex_open as codex
121
-
122
- terminated: list[bool] = []
123
- proc = SimpleNamespace(
124
- pid=123,
125
- poll=lambda: None,
126
- terminate=lambda: terminated.append(True),
127
- )
128
- monkeypatch.setattr(codex.subprocess, "Popen", lambda *args, **kwargs: proc)
129
- monkeypatch.setattr(codex, "_wait_for_port", lambda *args, **kwargs: True)
130
- monkeypatch.setattr(codex, "_server_alive", lambda *args, **kwargs: True)
131
- monkeypatch.setattr(codex, "_server_pid", lambda port: 456)
132
-
133
- try:
134
- codex._start_loaded_server("/tmp/array.npy", 8000, "sid", "array.npy")
135
- except RuntimeError as exc:
136
- assert "claimed by another process" in str(exc)
137
- else:
138
- raise AssertionError("PID mismatch must fail")
139
-
140
- assert terminated == [True]
141
-
142
-
143
- def test_codex_main_reports_actual_selected_port(monkeypatch, tmp_path, capsys):
144
- import arrayview._codex_open as codex
145
-
146
- filepath = tmp_path / "array.npy"
147
- filepath.write_bytes(b"data")
148
- monkeypatch.setattr(
149
- codex.sys,
150
- "argv",
151
- ["arrayview-codex", str(filepath), "--port", "8000"],
152
- )
153
- monkeypatch.setattr(
154
- codex,
155
- "_open_codex_file",
156
- lambda path, port: "http://localhost:8010/?sid=selected",
157
- )
158
-
159
- assert codex.main() == 0
160
- assert capsys.readouterr().out.strip() == "http://localhost:8010/?sid=selected"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes