Skip to content

Commit f650720

Browse files
committed
fix(vbgui): code-review hardening of JSON-RPC + cache + widget
Acts on the F-A..F-H code review findings. Each fix is pinned by a regression test in tests/v4/test_jsonrpc_review_fixes.py (13 cases). CRITICAL - C1 LRUCache.get/set now deep-copy via _isolate() (Pydantic model_copy(deep=True) for BaseModel, copy.deepcopy otherwise) so a cache hit cannot leak a shared mutable object to two callers. Adds ~150 µs per hit; verify cold still 1.6 ms, cached 0.2 ms. - C2 data.preview_parquet now distinguishes channels=None (emit all available side-channels) from channels=[] (emit none); both branches use different cache keys and produce different payloads. HIGH - H1 tokenizer _load() guarded by a module-level Lock so two concurrent first-loads on the same source share one Tokenizer instance. - H4 EncodeVisualizeParams._cache_key now uses sha256(text.encode()) instead of Python hash() — deterministic across processes, survives server restart, matches the cache.py docstring contract. - H8 FlowCanvas drop handler uses e.currentTarget (the canvas wrapper) for getBoundingClientRect instead of e.target (often a child node) — drop coordinates are now correct when the user lands on an existing node. MEDIUM/LOW - L3 widget.py _on_msg now also writes the dispatched outcome to the last_result traitlet (with method + id) so notebook Python code can observe RPC results without snooping on the wire. Error envelopes do NOT overwrite last_result. - M7 DataInspector resets channel toggles when the schema (token column OR available channels list) changes across loads, so a new parquet file's columns auto-enable instead of inheriting stale off-toggles. Test count delta: +13 review-fix tests + 1 existing test rewired (test_verify_cache_hit_short_circuits now asserts equality not identity, since the cache returns defensive copies). Full v4 regression: 2103 passed / 5 skipped / 15 xfailed / 0 failed. Vbgui: 77 tests passed (no churn).
1 parent 22a5a03 commit f650720

8 files changed

Lines changed: 328 additions & 22 deletions

File tree

cppmega_v4/jsonrpc/cache.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,25 @@
88

99
from __future__ import annotations
1010

11+
import copy
1112
import hashlib
1213
import json
1314
from collections import OrderedDict
1415
from threading import Lock
1516
from typing import Any, Mapping
1617

18+
try:
19+
from pydantic import BaseModel
20+
except ImportError: # pragma: no cover - pydantic is part of the gui extra
21+
BaseModel = None # type: ignore[assignment]
22+
23+
24+
def _isolate(value: Any) -> Any:
25+
"""Return a copy of ``value`` safe for an independent consumer."""
26+
if BaseModel is not None and isinstance(value, BaseModel):
27+
return value.model_copy(deep=True)
28+
return copy.deepcopy(value)
29+
1730

1831
DEFAULT_CAPACITY: int = 50
1932

@@ -81,21 +94,31 @@ def __len__(self) -> int:
8194
return len(self._data)
8295

8396
def get(self, key: str) -> Any | None:
97+
"""Return a fresh deep-copy of the cached value (or None on miss).
98+
99+
Hits MUST return a distinct object so that a downstream consumer
100+
mutating the result cannot corrupt the next reader. Pydantic
101+
models pay one model_copy(deep=True); plain dicts/lists pay one
102+
copy.deepcopy.
103+
"""
84104
with self._lock:
85105
if key not in self._data:
86106
self._misses += 1
87107
return None
88108
self._data.move_to_end(key)
89109
self._hits += 1
90-
return self._data[key]
110+
stored = self._data[key]
111+
return _isolate(stored)
91112

92113
def set(self, key: str, value: Any) -> None:
114+
"""Store a deep-copy so the caller can mutate the original safely."""
115+
snapshot = _isolate(value)
93116
with self._lock:
94117
if key in self._data:
95118
self._data.move_to_end(key)
96-
self._data[key] = value
119+
self._data[key] = snapshot
97120
return
98-
self._data[key] = value
121+
self._data[key] = snapshot
99122
if len(self._data) > self._capacity:
100123
self._data.popitem(last=False)
101124

cppmega_v4/jsonrpc/data_methods.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,11 @@ def preview_parquet(
7272
if params.offset < 0:
7373
raise ValueError(f"offset must be ≥ 0, got {params.offset}")
7474

75-
cache_key = f"preview::{params.path}::{params.offset}::{params.limit}::" \
76-
f"{','.join(sorted(params.channels or []))}"
75+
# Distinguish "no filter" (None → all channels) from "explicit empty
76+
# filter" ([] → drop all channels) in both cache key and selection.
77+
filter_tag = "ALL" if params.channels is None else \
78+
f"FILTER:{','.join(sorted(params.channels))}"
79+
cache_key = f"preview::{params.path}::{params.offset}::{params.limit}::{filter_tag}"
7780
if cache is not None:
7881
hit = cache.get(cache_key)
7982
if hit is not None:
@@ -93,8 +96,11 @@ def preview_parquet(
9396
# bookkeeping. Caller may further restrict via ``params.channels``.
9497
caps = introspect_parquet(params.path, sample_rows=min(params.limit, 64))
9598
available = sorted(c for c in caps.side_channels if c != token_col)
96-
selected = [c for c in available if not params.channels
97-
or c in params.channels]
99+
if params.channels is None:
100+
selected = available
101+
else:
102+
wanted = set(params.channels)
103+
selected = [c for c in available if c in wanted]
98104
cols_to_read = [token_col, *selected]
99105

100106
table = pf.read(columns=cols_to_read)

cppmega_v4/jsonrpc/tokenizer_methods.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
from __future__ import annotations
1515

16+
import hashlib
17+
import threading
1618
import time
1719
from dataclasses import dataclass
1820
from pathlib import Path
@@ -85,6 +87,7 @@ class _LoadedTokenizer:
8587

8688

8789
_TOKENIZER_CACHE: dict[str, _LoadedTokenizer] = {}
90+
_TOKENIZER_CACHE_LOCK = threading.Lock()
8891

8992

9093
def _load(source: str) -> _LoadedTokenizer:
@@ -94,11 +97,15 @@ def _load(source: str) -> _LoadedTokenizer:
9497
path = Path(source)
9598
if not path.is_file():
9699
raise FileNotFoundError(f"tokenizer source not found: {source}")
97-
tok = Tokenizer.from_file(str(path))
98-
caps = introspect_tokenizer(path)
99-
loaded = _LoadedTokenizer(tokenizer=tok, capabilities=caps)
100-
_TOKENIZER_CACHE[source] = loaded
101-
return loaded
100+
with _TOKENIZER_CACHE_LOCK:
101+
hit = _TOKENIZER_CACHE.get(source)
102+
if hit is not None:
103+
return hit
104+
tok = Tokenizer.from_file(str(path))
105+
caps = introspect_tokenizer(path)
106+
loaded = _LoadedTokenizer(tokenizer=tok, capabilities=caps)
107+
_TOKENIZER_CACHE[source] = loaded
108+
return loaded
102109

103110

104111
# ---------------------------------------------------------------------------
@@ -168,8 +175,11 @@ def list_presets() -> ListPresetsResult:
168175

169176

170177
def _cache_key(p: EncodeVisualizeParams) -> str:
171-
# No layout fields here, so canonical JSON via sorted-keys is fine.
172-
return f"tokenize::{p.tokenizer_source}::{p.add_special_tokens}::{hash(p.text)}"
178+
# SHA-256 over text so the key is stable across processes — Python's
179+
# built-in hash() is salted by PYTHONHASHSEED and would not survive a
180+
# server restart.
181+
text_digest = hashlib.sha256(p.text.encode("utf-8")).hexdigest()
182+
return f"tokenize::{p.tokenizer_source}::{p.add_special_tokens}::{text_digest}"
173183

174184

175185
def _caps_to_dict(c: TokenizerCapabilities) -> dict[str, Any]:

cppmega_v4/widget/widget.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,24 @@ def __init__(
8787
self.on_msg(self._on_msg)
8888

8989
def _on_msg(self, _: object, content: Any, _buffers: list[bytes]) -> None:
90-
"""Route a kernel-bound JSON-RPC envelope to the dispatcher."""
90+
"""Route a kernel-bound JSON-RPC envelope to the dispatcher.
91+
92+
Side effect: every successful dispatch also lands in the
93+
``last_result`` traitlet so Python notebook code can observe RPC
94+
results without snooping on the wire.
95+
"""
9196
if not isinstance(content, dict):
9297
_log.warning("VisualBuilderWidget: dropped non-dict msg %r", content)
9398
return
9499
response = dispatch(content, cache=self._cache)
95-
self.send(response.model_dump(mode="json", exclude_none=True))
100+
payload = response.model_dump(mode="json", exclude_none=True)
101+
self.send(payload)
102+
if response.result is not None:
103+
self.last_result = {
104+
"method": content.get("method"),
105+
"id": response.id,
106+
"result": response.result,
107+
}
96108

97109
@property
98110
def cache_stats(self) -> dict[str, int]:

tests/v4/test_jsonrpc_methods.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ def test_verify_cache_hit_short_circuits():
108108
params = _simple_verify_params()
109109
r1 = verify(params, cache=cache)
110110
r2 = verify(params, cache=cache)
111-
assert r1 is r2
111+
# Cache returns a defensive deep-copy on hit (C1 from review) so
112+
# consumers can mutate freely — assert equality, not identity.
113+
assert r1 == r2
114+
assert r1 is not r2
112115
stats = cache.stats()
113116
assert stats["hits"] == 1 and stats["misses"] == 1
114117

0 commit comments

Comments
 (0)