arrayview 0.35.3__tar.gz → 0.37.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arrayview-0.35.3 → arrayview-0.37.1}/PKG-INFO +1 -1
- {arrayview-0.35.3 → arrayview-0.37.1}/pyproject.toml +1 -1
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_io.py +84 -9
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_launcher.py +77 -30
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_loading.py +9 -3
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_query.py +8 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_session.py +48 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_viewer.html +64 -15
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vscode_extension.py +46 -2
- arrayview-0.37.1/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_api.py +3 -3
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_cli.py +10 -2
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_doctor_commands.py +30 -13
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_instance_registry.py +3 -1
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_invocation_contract_matrix.py +2 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_diagnostics.py +3 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_execution_ownership.py +4 -2
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_plan.py +5 -2
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_lifecycle_contract.py +15 -5
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_public_invocation_regressions.py +1 -0
- arrayview-0.37.1/tests/test_value_formatting.py +126 -0
- arrayview-0.35.3/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/.gitignore +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/LICENSE +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/README.md +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/__init__.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/__main__.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_app.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_codex_open.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_config.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_diff.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_icon.png +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_platform.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_render.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_routes_websocket.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_server.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_shell.html +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_torch.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/src/arrayview/todo.md +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/capture_v_animation.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/conftest.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/lifecycle_matrix.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_backend_shared.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_browser.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_codex_open.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_command_reachability.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_config.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_cross_mode_parametrized.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_dicom.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_drop_import.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_interactions.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_io.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_large_arrays.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_launch_trace.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_loading_server.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_nifti_series.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_preferences_ui.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_session_spec.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_torch.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_view_component_integration.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_view_component_unit.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/test_vscode_ack_protocol.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/ui_audit.py +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.35.3 → arrayview-0.37.1}/tests/visual_smoke.py +0 -0
|
@@ -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
|
-
#
|
|
1434
|
-
#
|
|
1435
|
-
#
|
|
1436
|
-
#
|
|
1437
|
-
#
|
|
1438
|
-
|
|
1439
|
-
if
|
|
1440
|
-
return np.load(filepath)
|
|
1441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1863
|
+
"`arrayview stop` or choose a free port with "
|
|
1864
1864
|
f"`--port`. ({e})"
|
|
1865
1865
|
)
|
|
1866
1866
|
sys.exit(1)
|
|
@@ -2590,6 +2590,11 @@ _PERSIST_DAEMON_CONNECT_TIMEOUT_SECONDS = float(
|
|
|
2590
2590
|
_PERSIST_DAEMON_IDLE_SECONDS = float(
|
|
2591
2591
|
os.environ.get("ARRAYVIEW_PERSIST_IDLE_SECONDS", "1800")
|
|
2592
2592
|
)
|
|
2593
|
+
# How long a daemon whose initial load failed keeps serving so the opener can
|
|
2594
|
+
# read the reason from /metadata/<sid> before the port is released.
|
|
2595
|
+
_FAILED_LOAD_EXIT_GRACE_SECONDS = float(
|
|
2596
|
+
os.environ.get("ARRAYVIEW_FAILED_LOAD_EXIT_GRACE_SECONDS", "30")
|
|
2597
|
+
)
|
|
2593
2598
|
|
|
2594
2599
|
|
|
2595
2600
|
def _join_query_values(values: _CSVValues) -> str:
|
|
@@ -4169,6 +4174,7 @@ def _serve_daemon(
|
|
|
4169
4174
|
owner="persistent" if persist else "transient",
|
|
4170
4175
|
)
|
|
4171
4176
|
# Register sid as pending so /metadata can poll while data loads.
|
|
4177
|
+
_initial_load_failed = threading.Event()
|
|
4172
4178
|
_session_mod.PENDING_SESSIONS.add(sid)
|
|
4173
4179
|
_pending_event = threading.Event()
|
|
4174
4180
|
_session_mod.PENDING_SESSION_EVENTS[sid] = _pending_event
|
|
@@ -4301,6 +4307,7 @@ def _serve_daemon(
|
|
|
4301
4307
|
f"[ArrayView] Warning: failed to load vector field {vfield_filepath}: {e}",
|
|
4302
4308
|
flush=True,
|
|
4303
4309
|
)
|
|
4310
|
+
_session_mod.upgrade_memmap_in_background(session)
|
|
4304
4311
|
_session_mod.SESSIONS[session.sid] = session
|
|
4305
4312
|
if dir_overlay_items is not None:
|
|
4306
4313
|
for item, ov_sid in zip(dir_overlay_items, overlay_sids or []):
|
|
@@ -4349,6 +4356,18 @@ def _serve_daemon(
|
|
|
4349
4356
|
sid_tag=sid_tag,
|
|
4350
4357
|
error_type=type(exc).__name__,
|
|
4351
4358
|
)
|
|
4359
|
+
# This runs on a daemon thread, so re-raising only kills the thread
|
|
4360
|
+
# and writes to a stderr nobody reads. Record why the sid will never
|
|
4361
|
+
# become ready so /metadata can report it instead of returning 404
|
|
4362
|
+
# until the opener gives up.
|
|
4363
|
+
_session_mod.FAILED_PENDING_SESSIONS[sid] = (
|
|
4364
|
+
str(exc) or type(exc).__name__
|
|
4365
|
+
)
|
|
4366
|
+
_vprint(
|
|
4367
|
+
f"[ArrayView] Failed to load {filepath}: {exc}",
|
|
4368
|
+
flush=True,
|
|
4369
|
+
)
|
|
4370
|
+
_initial_load_failed.set()
|
|
4352
4371
|
raise
|
|
4353
4372
|
finally:
|
|
4354
4373
|
_session_mod.PENDING_SESSIONS.discard(sid)
|
|
@@ -4357,6 +4376,32 @@ def _serve_daemon(
|
|
|
4357
4376
|
|
|
4358
4377
|
threading.Thread(target=_load, daemon=True).start()
|
|
4359
4378
|
|
|
4379
|
+
def _exit_if_initial_load_failed() -> None:
|
|
4380
|
+
"""Release the port when the file this daemon exists to serve won't load.
|
|
4381
|
+
|
|
4382
|
+
uvicorn starts before the load finishes, so a failed load otherwise
|
|
4383
|
+
leaves a server with no session holding the port until the connect
|
|
4384
|
+
timeout expires minutes later — and answering /ping the whole time. The
|
|
4385
|
+
grace period is only so the opener can read the failure from
|
|
4386
|
+
/metadata/<sid> before the port goes away.
|
|
4387
|
+
"""
|
|
4388
|
+
_initial_load_failed.wait()
|
|
4389
|
+
time.sleep(_FAILED_LOAD_EXIT_GRACE_SECONDS)
|
|
4390
|
+
if _session_mod.SESSIONS or _session_mod.VIEWER_SOCKETS:
|
|
4391
|
+
return # something else is using this server; leave it alone
|
|
4392
|
+
_trace_launch_event(
|
|
4393
|
+
"daemon.exiting",
|
|
4394
|
+
reason="initial_load_failed",
|
|
4395
|
+
instance_tag=_launch_trace_tag(record.instance_id),
|
|
4396
|
+
)
|
|
4397
|
+
try:
|
|
4398
|
+
registry.remove(record.instance_id)
|
|
4399
|
+
except Exception:
|
|
4400
|
+
pass
|
|
4401
|
+
os._exit(0)
|
|
4402
|
+
|
|
4403
|
+
threading.Thread(target=_exit_if_initial_load_failed, daemon=True).start()
|
|
4404
|
+
|
|
4360
4405
|
if persist:
|
|
4361
4406
|
_wait_for_viewer_close(
|
|
4362
4407
|
idle_seconds=_PERSIST_DAEMON_IDLE_SECONDS,
|
|
@@ -4771,9 +4816,29 @@ def _instance_public_dict(record) -> dict[str, object]:
|
|
|
4771
4816
|
return value
|
|
4772
4817
|
|
|
4773
4818
|
|
|
4819
|
+
def _run_stop_command() -> None:
|
|
4820
|
+
"""Stop every running ArrayView instance and clean up stale state."""
|
|
4821
|
+
from arrayview._instance_registry import InstanceRegistry # noqa: PLC0415
|
|
4822
|
+
|
|
4823
|
+
records = InstanceRegistry().discover(clean_stale=True)
|
|
4824
|
+
if not records:
|
|
4825
|
+
print("[ArrayView] No running ArrayView instances.")
|
|
4826
|
+
for record in records:
|
|
4827
|
+
message, _pid = _stop_verified_server(record.port)
|
|
4828
|
+
print(f"[ArrayView] {record.instance_id}: {message}")
|
|
4829
|
+
|
|
4830
|
+
from arrayview._vscode_signal import ( # noqa: PLC0415
|
|
4831
|
+
_cleanup_zombie_registrations,
|
|
4832
|
+
)
|
|
4833
|
+
|
|
4834
|
+
cleaned = _cleanup_zombie_registrations(verbose=True)
|
|
4835
|
+
if cleaned:
|
|
4836
|
+
print(f"[ArrayView] Cleaned {cleaned} zombie window registration(s)")
|
|
4837
|
+
|
|
4838
|
+
|
|
4774
4839
|
def _handle_management_command(argv: list[str]) -> bool:
|
|
4775
4840
|
"""Handle dependency-light management subcommands before the file CLI."""
|
|
4776
|
-
if not argv or argv[0] not in {"doctor", "instances", "stop"}:
|
|
4841
|
+
if not argv or argv[0] not in {"doctor", "instances", "stop", "kill"}:
|
|
4777
4842
|
return False
|
|
4778
4843
|
command = argv[0]
|
|
4779
4844
|
parser = argparse.ArgumentParser(prog=f"arrayview {command}")
|
|
@@ -4799,22 +4864,10 @@ def _handle_management_command(argv: list[str]) -> bool:
|
|
|
4799
4864
|
)
|
|
4800
4865
|
return True
|
|
4801
4866
|
|
|
4802
|
-
if command
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
args = parser.parse_args(argv[1:])
|
|
4807
|
-
records = InstanceRegistry().discover(clean_stale=True)
|
|
4808
|
-
selected = (
|
|
4809
|
-
records
|
|
4810
|
-
if args.all
|
|
4811
|
-
else [record for record in records if record.instance_id == args.instance_id]
|
|
4812
|
-
)
|
|
4813
|
-
if not selected:
|
|
4814
|
-
parser.error("no matching running ArrayView instance")
|
|
4815
|
-
for record in selected:
|
|
4816
|
-
message, _pid = _stop_verified_server(record.port)
|
|
4817
|
-
print(f"[ArrayView] {record.instance_id}: {message}")
|
|
4867
|
+
if command in {"stop", "kill"}:
|
|
4868
|
+
parser.description = "Stop every running ArrayView server."
|
|
4869
|
+
parser.parse_args(argv[1:])
|
|
4870
|
+
_run_stop_command()
|
|
4818
4871
|
return True
|
|
4819
4872
|
|
|
4820
4873
|
parser.add_argument("--json", action="store_true")
|
|
@@ -4958,7 +5011,7 @@ def arrayview():
|
|
|
4958
5011
|
parser.add_argument(
|
|
4959
5012
|
"--kill",
|
|
4960
5013
|
action="store_true",
|
|
4961
|
-
help="
|
|
5014
|
+
help="Deprecated: use `arrayview stop`",
|
|
4962
5015
|
)
|
|
4963
5016
|
parser.add_argument(
|
|
4964
5017
|
"--overlay",
|
|
@@ -5306,15 +5359,9 @@ def arrayview():
|
|
|
5306
5359
|
sys.exit(1)
|
|
5307
5360
|
return
|
|
5308
5361
|
|
|
5309
|
-
# -- --kill:
|
|
5362
|
+
# -- --kill: deprecated alias for `arrayview stop` --
|
|
5310
5363
|
if args.kill:
|
|
5311
|
-
|
|
5312
|
-
print(f"[ArrayView] {message}")
|
|
5313
|
-
|
|
5314
|
-
from arrayview._vscode_signal import _cleanup_zombie_registrations
|
|
5315
|
-
cleaned = _cleanup_zombie_registrations(verbose=True)
|
|
5316
|
-
if cleaned:
|
|
5317
|
-
print(f"[ArrayView] Cleaned {cleaned} zombie window registration(s)")
|
|
5364
|
+
_run_stop_command()
|
|
5318
5365
|
return
|
|
5319
5366
|
|
|
5320
5367
|
# -- --serve: start a persistent empty server and exit --
|
|
@@ -5359,7 +5406,7 @@ def arrayview():
|
|
|
5359
5406
|
if serve_context.evidence.is_vscode_remote:
|
|
5360
5407
|
print(
|
|
5361
5408
|
f"[ArrayView] Port {args.port} was claimed while starting.\n"
|
|
5362
|
-
|
|
5409
|
+
" Run 'arrayview stop' to free it, "
|
|
5363
5410
|
"or use --port to specify a different port.",
|
|
5364
5411
|
flush=True,
|
|
5365
5412
|
)
|
|
@@ -5631,7 +5678,7 @@ def arrayview():
|
|
|
5631
5678
|
if launch_plan.failure is LaunchFailure.REMOTE_PORT_CONFLICT:
|
|
5632
5679
|
print(
|
|
5633
5680
|
f"[ArrayView] Port {args.port} is in use by another process.\n"
|
|
5634
|
-
|
|
5681
|
+
" Run 'arrayview stop' to free it, "
|
|
5635
5682
|
f"or use --port to specify a different port.",
|
|
5636
5683
|
flush=True,
|
|
5637
5684
|
)
|
|
@@ -18,11 +18,13 @@ from arrayview._lifecycle import (
|
|
|
18
18
|
)
|
|
19
19
|
from arrayview._session import (
|
|
20
20
|
CANCELLED_PENDING_SESSIONS,
|
|
21
|
+
FAILED_PENDING_SESSIONS,
|
|
21
22
|
PENDING_SESSION_EVENTS,
|
|
22
23
|
PENDING_SESSIONS,
|
|
23
24
|
SESSIONS,
|
|
24
25
|
Session,
|
|
25
26
|
file_signature,
|
|
27
|
+
upgrade_memmap_in_background,
|
|
26
28
|
wait_for_session_ready,
|
|
27
29
|
)
|
|
28
30
|
|
|
@@ -263,6 +265,7 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
|
|
|
263
265
|
# the real Session instead of seeing a transient 404.
|
|
264
266
|
sid = requested_sid or uuid.uuid4().hex
|
|
265
267
|
pending_event = threading.Event()
|
|
268
|
+
FAILED_PENDING_SESSIONS.pop(sid, None)
|
|
266
269
|
PENDING_SESSIONS.add(sid)
|
|
267
270
|
PENDING_SESSION_EVENTS[sid] = pending_event
|
|
268
271
|
signature_before_load = file_signature(abs_path)
|
|
@@ -291,14 +294,17 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
|
|
|
291
294
|
session.related_release_sids = [
|
|
292
295
|
str(value) for value in body.get("related_sids", [])
|
|
293
296
|
]
|
|
297
|
+
upgrade_memmap_in_background(session)
|
|
294
298
|
commit_pending_session(sid, session)
|
|
295
299
|
except Exception as exc:
|
|
296
|
-
# Keep the server alive and
|
|
297
|
-
#
|
|
298
|
-
#
|
|
300
|
+
# Keep the server alive, and record why this sid will never
|
|
301
|
+
# become ready. Without the record the opener cannot tell a
|
|
302
|
+
# failed load from a slow one and burns its whole deadline
|
|
303
|
+
# on a file that raised immediately.
|
|
299
304
|
import traceback
|
|
300
305
|
|
|
301
306
|
traceback.print_exc()
|
|
307
|
+
FAILED_PENDING_SESSIONS[sid] = str(exc) or exc.__class__.__name__
|
|
302
308
|
finally:
|
|
303
309
|
PENDING_SESSIONS.discard(sid)
|
|
304
310
|
CANCELLED_PENDING_SESSIONS.discard(sid)
|
|
@@ -211,6 +211,14 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
|
|
|
211
211
|
# request; the opener retries until the pending Session is published.
|
|
212
212
|
if sid in _session_mod.PENDING_SESSIONS:
|
|
213
213
|
return Response(status_code=404, headers={"Retry-After": "1"})
|
|
214
|
+
# A load that raised is terminal. Say so with a distinct status and the
|
|
215
|
+
# real reason, so the caller stops polling and can show the user why
|
|
216
|
+
# this file will never open instead of waiting out its deadline.
|
|
217
|
+
load_error = _session_mod.FAILED_PENDING_SESSIONS.get(sid)
|
|
218
|
+
if load_error is not None:
|
|
219
|
+
return Response(
|
|
220
|
+
status_code=422, content=load_error.encode(), media_type="text/plain"
|
|
221
|
+
)
|
|
214
222
|
session = await wait_for_session_ready(sid)
|
|
215
223
|
if not session:
|
|
216
224
|
return Response(status_code=404)
|
|
@@ -41,6 +41,10 @@ _window_process = None
|
|
|
41
41
|
PENDING_SESSIONS: set = set() # sids whose data is still loading in a background thread
|
|
42
42
|
PENDING_SESSION_EVENTS: dict[str, threading.Event] = {}
|
|
43
43
|
CANCELLED_PENDING_SESSIONS: set[str] = set()
|
|
44
|
+
# sid -> why its background load failed. A load that raises must be reportable:
|
|
45
|
+
# without this the opener cannot tell "still loading" from "will never load" and
|
|
46
|
+
# waits out its whole deadline on a file that failed in the first second.
|
|
47
|
+
FAILED_PENDING_SESSIONS: dict[str, str] = {}
|
|
44
48
|
|
|
45
49
|
|
|
46
50
|
def file_signature(filepath: str):
|
|
@@ -454,6 +458,49 @@ def _startup_dims_for_data(data, shape=None) -> tuple[int, int] | None:
|
|
|
454
458
|
return _viewer_start_dims_for_shape(target_shape)
|
|
455
459
|
|
|
456
460
|
|
|
461
|
+
def upgrade_memmap_in_background(session) -> bool:
|
|
462
|
+
"""Read a memmap-backed session into RAM behind the viewer.
|
|
463
|
+
|
|
464
|
+
``load_data`` hands back a memmap only when the first frame is cheap to
|
|
465
|
+
fault in, which buys an immediate picture but leaves later orientations
|
|
466
|
+
paying scattered reads. The sequential read still has to happen, so run it
|
|
467
|
+
off the hot path and swap the array in when it lands.
|
|
468
|
+
|
|
469
|
+
Rebinding ``session.data`` is atomic under the GIL and the values are
|
|
470
|
+
identical, so a render already holding the memmap stays correct and the
|
|
471
|
+
slice caches stay valid.
|
|
472
|
+
"""
|
|
473
|
+
data = getattr(session, "data", None)
|
|
474
|
+
filepath = getattr(session, "filepath", None)
|
|
475
|
+
if not isinstance(data, np.memmap) or not filepath:
|
|
476
|
+
return False
|
|
477
|
+
if not str(filepath).endswith(".npy"):
|
|
478
|
+
return False
|
|
479
|
+
try:
|
|
480
|
+
from ._io import npy_eager_limit
|
|
481
|
+
|
|
482
|
+
if os.path.getsize(filepath) >= npy_eager_limit():
|
|
483
|
+
return False # does not fit in RAM — the map is the only option
|
|
484
|
+
except OSError:
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
expected = data
|
|
488
|
+
version = getattr(session, "data_version", 0)
|
|
489
|
+
|
|
490
|
+
def _upgrade():
|
|
491
|
+
try:
|
|
492
|
+
loaded = np.load(filepath)
|
|
493
|
+
except Exception:
|
|
494
|
+
return # keep serving from the map
|
|
495
|
+
# A /reload or a released session must not be clobbered by a read that
|
|
496
|
+
# started against the previous array.
|
|
497
|
+
if session.data is expected and getattr(session, "data_version", 0) == version:
|
|
498
|
+
session.data = loaded
|
|
499
|
+
|
|
500
|
+
threading.Thread(target=_upgrade, daemon=True).start()
|
|
501
|
+
return True
|
|
502
|
+
|
|
503
|
+
|
|
457
504
|
SESSIONS = {}
|
|
458
505
|
|
|
459
506
|
COLORMAPS = [
|
|
@@ -575,6 +622,7 @@ __all__ = [
|
|
|
575
622
|
"_window_process",
|
|
576
623
|
"PENDING_SESSIONS",
|
|
577
624
|
"PENDING_SESSION_EVENTS",
|
|
625
|
+
"FAILED_PENDING_SESSIONS",
|
|
578
626
|
"wait_for_session_ready",
|
|
579
627
|
# Render thread pool
|
|
580
628
|
"_RENDER_QUEUE",
|
|
@@ -9278,7 +9278,20 @@
|
|
|
9278
9278
|
function formatValueGroup(values, options = {}) {
|
|
9279
9279
|
const finite = (Array.isArray(values) ? values : [values]).filter(Number.isFinite);
|
|
9280
9280
|
const cap = Math.max(0, Math.min(8, Math.trunc(options.maxDecimals ?? 7)));
|
|
9281
|
-
|
|
9281
|
+
// Decimals worth showing depend on magnitude. Two was applied at
|
|
9282
|
+
// every scale, so a range topping out near 1890 read as "1890.04"
|
|
9283
|
+
// when the hundredths carry no information. The floor is only ever
|
|
9284
|
+
// lowered for large values, never raised, so small ranges keep the
|
|
9285
|
+
// precision they had. The search below can still add decimals when
|
|
9286
|
+
// values would otherwise collide.
|
|
9287
|
+
const _defaultMinDecimals = (vals) => {
|
|
9288
|
+
const m = Math.max(...vals.filter(Number.isFinite).map(v => Math.abs(v)));
|
|
9289
|
+
if (!(m > 0) || !Number.isFinite(m)) return 2;
|
|
9290
|
+
return Math.max(0, Math.min(2, 3 - Math.floor(Math.log10(m))));
|
|
9291
|
+
};
|
|
9292
|
+
const minDecimals = Math.max(0, Math.min(cap, Math.trunc(
|
|
9293
|
+
options.minDecimals ?? _defaultMinDecimals(finite)
|
|
9294
|
+
)));
|
|
9282
9295
|
const ordinaryLo = options.ordinaryLo ?? 1e-4;
|
|
9283
9296
|
const ordinaryHi = options.ordinaryHi ?? 1e6;
|
|
9284
9297
|
const fallback = {
|
|
@@ -14242,6 +14255,41 @@
|
|
|
14242
14255
|
|
|
14243
14256
|
/* ── JS: Initialization and Metadata Fetch ────────────────── */
|
|
14244
14257
|
|
|
14258
|
+
// The backend serves this page as soon as its port is up, which can be
|
|
14259
|
+
// seconds before a large array has finished loading. While the session
|
|
14260
|
+
// is still loading /metadata answers 404 with a Retry-After header; an
|
|
14261
|
+
// unknown or released sid answers 404 without one, and a load that
|
|
14262
|
+
// failed answers 422 with the reason. Keep waiting only for the first.
|
|
14263
|
+
async function fetchMetadataWithRetry(sid, timeoutMs = 180000) {
|
|
14264
|
+
const deadline = Date.now() + timeoutMs;
|
|
14265
|
+
for (;;) {
|
|
14266
|
+
const res = await proxyFetch(`/metadata/${sid}`);
|
|
14267
|
+
if (res.ok) return { data: await res.json() };
|
|
14268
|
+
if (res.status === 422) {
|
|
14269
|
+
let reason = '';
|
|
14270
|
+
try { reason = (await res.text()).trim(); } catch (_e) {}
|
|
14271
|
+
return { error: reason || 'This file could not be loaded.', status: 422 };
|
|
14272
|
+
}
|
|
14273
|
+
const pending = res.status === 404 && res.headers.get('Retry-After');
|
|
14274
|
+
if (!pending || Date.now() >= deadline) {
|
|
14275
|
+
return { error: null, status: res.status };
|
|
14276
|
+
}
|
|
14277
|
+
await new Promise(r => setTimeout(r, 300));
|
|
14278
|
+
}
|
|
14279
|
+
}
|
|
14280
|
+
|
|
14281
|
+
function showMetadataError(result) {
|
|
14282
|
+
const msg = result.error
|
|
14283
|
+
? result.error
|
|
14284
|
+
: (result.status === 404
|
|
14285
|
+
? 'Session not found or expired.'
|
|
14286
|
+
: `Server error ${result.status} — check Python console for details.`);
|
|
14287
|
+
document.getElementById('loading-overlay').innerHTML =
|
|
14288
|
+
`<div style="color:#c55;font-size:16px;font-family:monospace;max-width:60ch;text-align:center;line-height:1.5;padding:0 2em">${
|
|
14289
|
+
msg.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
14290
|
+
}</div>`;
|
|
14291
|
+
}
|
|
14292
|
+
|
|
14245
14293
|
async function init() {
|
|
14246
14294
|
// Loading spinner is in static HTML — body.av-loading set by server template.
|
|
14247
14295
|
if (!sid) { hideLoadingOverlay(); drawPlasmaDemo(); return; }
|
|
@@ -14260,26 +14308,24 @@
|
|
|
14260
14308
|
);
|
|
14261
14309
|
data = await Promise.race([_metadataFromWs, httpFallback]);
|
|
14262
14310
|
if (!data) {
|
|
14263
|
-
// Both failed —
|
|
14264
|
-
const
|
|
14265
|
-
if (!
|
|
14266
|
-
reportParent('metadata-http-error', { status:
|
|
14267
|
-
|
|
14268
|
-
document.getElementById('loading-overlay').innerHTML = `<div style="color:#c55;font-size:18px;font-family:monospace">${msg}</div>`;
|
|
14311
|
+
// Both failed — wait out a still-loading session.
|
|
14312
|
+
const result = await fetchMetadataWithRetry(sid);
|
|
14313
|
+
if (!result.data) {
|
|
14314
|
+
reportParent('metadata-http-error', { status: result.status, sid });
|
|
14315
|
+
showMetadataError(result);
|
|
14269
14316
|
return;
|
|
14270
14317
|
}
|
|
14271
|
-
data =
|
|
14318
|
+
data = result.data;
|
|
14272
14319
|
}
|
|
14273
14320
|
} else {
|
|
14274
14321
|
// HTTP transport mode (VS Code) — use traditional fetch
|
|
14275
|
-
const
|
|
14276
|
-
if (!
|
|
14277
|
-
reportParent('metadata-http-error', { status:
|
|
14278
|
-
|
|
14279
|
-
document.getElementById('loading-overlay').innerHTML = `<div style="color:#c55;font-size:18px;font-family:monospace">${msg}</div>`;
|
|
14322
|
+
const result = await fetchMetadataWithRetry(sid);
|
|
14323
|
+
if (!result.data) {
|
|
14324
|
+
reportParent('metadata-http-error', { status: result.status, sid });
|
|
14325
|
+
showMetadataError(result);
|
|
14280
14326
|
return;
|
|
14281
14327
|
}
|
|
14282
|
-
data =
|
|
14328
|
+
data = result.data;
|
|
14283
14329
|
}
|
|
14284
14330
|
|
|
14285
14331
|
await _loadOverlayLabelValues();
|
|
@@ -35919,9 +35965,12 @@
|
|
|
35919
35965
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
35920
35966
|
}).join(' ');
|
|
35921
35967
|
const distLabel = distance.toFixed(1) + ' px';
|
|
35968
|
+
// Share the colorbar's formatter so the same value does not read
|
|
35969
|
+
// differently here than it does on the colorbar.
|
|
35970
|
+
const _lpFmt = formatValueGroup([vMin, vMax]);
|
|
35922
35971
|
const fmtVal = v => {
|
|
35923
35972
|
if (v === null || !isFinite(v)) return '?';
|
|
35924
|
-
return
|
|
35973
|
+
return _lpFmt.format(v);
|
|
35925
35974
|
};
|
|
35926
35975
|
_lineProfileOverlay.innerHTML = `
|
|
35927
35976
|
<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -54,7 +54,7 @@ _VSCODE_EXT_INSTALLED = False # cached so we only check once per process
|
|
|
54
54
|
_VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
|
|
55
55
|
_VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
|
|
56
56
|
_VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
|
|
57
|
-
_VSCODE_EXT_VERSION = "0.14.
|
|
57
|
+
_VSCODE_EXT_VERSION = "0.14.88" # current bundled extension version
|
|
58
58
|
_VSCODE_CONFIGURED_PORTS: set[int] = set()
|
|
59
59
|
|
|
60
60
|
def _bundled_vscode_vsix_version(vsix_path: str) -> str | None:
|
|
@@ -633,6 +633,47 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
|
|
|
633
633
|
# ---------------------------------------------------------------------------
|
|
634
634
|
|
|
635
635
|
|
|
636
|
+
def _port_has_listener(port: int) -> bool:
|
|
637
|
+
"""Return True when something is listening on ``port`` on loopback."""
|
|
638
|
+
import socket
|
|
639
|
+
|
|
640
|
+
for family, addr in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")):
|
|
641
|
+
sock = socket.socket(family, socket.SOCK_STREAM)
|
|
642
|
+
try:
|
|
643
|
+
sock.settimeout(0.15)
|
|
644
|
+
if sock.connect_ex((addr, port)) == 0:
|
|
645
|
+
return True
|
|
646
|
+
except OSError:
|
|
647
|
+
continue
|
|
648
|
+
finally:
|
|
649
|
+
sock.close()
|
|
650
|
+
return False
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _stale_arrayview_port_keys(attrs: dict, keep_port: int) -> list[str]:
|
|
654
|
+
"""Return ArrayView port entries that no longer have a live server.
|
|
655
|
+
|
|
656
|
+
Every launch on a fresh port used to leave its entry behind for good. On a
|
|
657
|
+
tunnel those entries keep telling VS Code to forward ports nothing serves,
|
|
658
|
+
which competes for a limited per-tunnel forwarding budget. Only entries
|
|
659
|
+
ArrayView wrote are considered, and only when their port is dead, so a
|
|
660
|
+
second concurrent ArrayView server is never disturbed.
|
|
661
|
+
"""
|
|
662
|
+
stale: list[str] = []
|
|
663
|
+
for key, value in list(attrs.items()):
|
|
664
|
+
if not isinstance(value, dict) or value.get("label") != "ArrayView":
|
|
665
|
+
continue
|
|
666
|
+
if key == str(keep_port):
|
|
667
|
+
continue
|
|
668
|
+
try:
|
|
669
|
+
candidate = int(key)
|
|
670
|
+
except (TypeError, ValueError):
|
|
671
|
+
continue
|
|
672
|
+
if not _port_has_listener(candidate):
|
|
673
|
+
stale.append(key)
|
|
674
|
+
return stale
|
|
675
|
+
|
|
676
|
+
|
|
636
677
|
def _configure_vscode_port_preview(
|
|
637
678
|
port: int,
|
|
638
679
|
*,
|
|
@@ -686,8 +727,11 @@ def _configure_vscode_port_preview(
|
|
|
686
727
|
}
|
|
687
728
|
current = attrs.get(str(port))
|
|
688
729
|
updated = {**current, **desired} if isinstance(current, dict) else desired
|
|
689
|
-
|
|
730
|
+
stale = _stale_arrayview_port_keys(attrs, port)
|
|
731
|
+
if current == updated and not stale:
|
|
690
732
|
return
|
|
733
|
+
for key in stale:
|
|
734
|
+
attrs.pop(key, None)
|
|
691
735
|
attrs[str(port)] = updated
|
|
692
736
|
with open(path, "w") as f:
|
|
693
737
|
json.dump(settings, f, indent=2)
|
|
Binary file
|