Skip to content

Commit 82c4112

Browse files
committed
merge: token-count memo rejects cross-tokenizer counts under concurrent rebind (headroomlabs-ai#2710)
2 parents 10d9daa + 88320dc commit 82c4112

2 files changed

Lines changed: 146 additions & 17 deletions

File tree

headroom/cache/token_count_memo.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ class TokenCountMemo:
4646
to a fresh ``EstimatingTokenCounter`` mid-session — clears the memo and
4747
rebinds. The registry caches tokenizer instances per model, so on the
4848
normal path the binding is stable across a session's requests.
49+
50+
The store is per-session and a session's requests run concurrently, so
51+
binding alone is not enough: request A can bind tokenizer A, release the
52+
lock, and still be mid-count when request B rebinds to tokenizer B (the
53+
fail-open path builds a *fresh* estimator per request — see
54+
``proxy/token_counting._count_offloaded``). ``get``/``put`` therefore
55+
re-check tokenizer identity under the same lock that owns the binding, so
56+
an obsolete binding's reads miss and its writes are dropped rather than
57+
leaking counts across tokenizers. That makes each lookup/populate atomic
58+
with respect to tokenizer identity without holding the lock across the
59+
(slow) tokenizer call itself.
4960
"""
5061

5162
def __init__(self, max_entries: int = 10000) -> None:
@@ -83,8 +94,16 @@ def message_hash(message: dict[str, Any]) -> str:
8394
raw = TokenCountMemo.canonical_message(message)
8495
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
8596

86-
def get(self, key: str, canonical: str | None = None) -> int | None:
97+
def get(self, key: str, canonical: str | None = None, *, tokenizer: object) -> int | None:
98+
"""Read a count, but only for the tokenizer currently bound.
99+
100+
``tokenizer`` is required so no caller can silently opt out of the
101+
identity check: a stale binding must miss, not serve another
102+
tokenizer's numbers.
103+
"""
87104
with self._lock:
105+
if self._bound_tokenizer is not tokenizer:
106+
return None
88107
entries = self._counts.get(key)
89108
if entries is None:
90109
return None
@@ -93,8 +112,16 @@ def get(self, key: str, canonical: str | None = None) -> int | None:
93112
return entries[0][1]
94113
return next((count for stored, count in entries if stored == canonical), None)
95114

96-
def put(self, key: str, count: int, canonical: str | None = None) -> None:
115+
def put(self, key: str, count: int, canonical: str | None = None, *, tokenizer: object) -> None:
116+
"""Store a count, dropping writes from an obsolete tokenizer binding.
117+
118+
A concurrent rebind (another request's tokenizer swap) already cleared
119+
the store for its own tokenizer; letting this write land would mark a
120+
stale count as belonging to the new binding.
121+
"""
97122
with self._lock:
123+
if self._bound_tokenizer is not tokenizer:
124+
return
98125
canonical = key if canonical is None else canonical
99126
entries = self._counts.pop(key, [])
100127
entries = [(stored, value) for stored, value in entries if stored != canonical]
@@ -121,6 +148,11 @@ def count_messages_memoized(
121148
``count_messages`` call instead. The memo is also bound to the tokenizer
122149
instance — a swap (count fail-open downgrade) clears stale counts rather
123150
than serving numbers computed under a different tokenizer.
151+
152+
Every ``get``/``put`` re-asserts that identity under the memo lock, so a
153+
concurrent rebind on the shared per-session memo degrades this call to
154+
uncached counting (all values still produced by ``tokenizer``) instead of
155+
trading counts with the other request's tokenizer.
124156
"""
125157
if not getattr(tokenizer, "ADDITIVE_COUNTS", False):
126158
return tokenizer.count_messages(messages)
@@ -129,9 +161,9 @@ def count_messages_memoized(
129161
for msg in messages:
130162
canonical = TokenCountMemo.canonical_message(msg)
131163
key = TokenCountMemo.message_hash(msg)
132-
count = memo.get(key, canonical)
164+
count = memo.get(key, canonical, tokenizer=tokenizer)
133165
if count is None:
134166
count = tokenizer.count_message(msg)
135-
memo.put(key, count, canonical)
167+
memo.put(key, count, canonical, tokenizer=tokenizer)
136168
total += count
137169
return total + tokenizer.REPLY_OVERHEAD

tests/test_token_count_memo.py

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from __future__ import annotations
1313

14+
import threading
15+
1416
import pytest
1517

1618
from headroom.cache.token_count_memo import TokenCountMemo, count_messages_memoized
@@ -133,7 +135,7 @@ def test_memoized_count_handles_append_only_delta() -> None:
133135

134136
count_messages_memoized(memo, tokenizer, turn1)
135137
prefix_key = TokenCountMemo.message_hash(turn1[0])
136-
assert memo.get(prefix_key) is not None
138+
assert memo.get(prefix_key, tokenizer=tokenizer) is not None
137139

138140
memoized_turn2 = count_messages_memoized(memo, tokenizer, turn2)
139141
assert memoized_turn2 == tokenizer.count_messages(turn2)
@@ -174,21 +176,25 @@ def count_messages(self, messages) -> int: # noqa: ANN001
174176
class TestTokenCountMemoEviction:
175177
def test_eviction_at_max_entries(self) -> None:
176178
memo = TokenCountMemo(max_entries=3)
177-
memo.put("a", 1)
178-
memo.put("b", 2)
179-
memo.put("c", 3)
180-
memo.get("a") # touch "a" so it's not the least-recently-used
181-
memo.put("d", 4) # should evict "b" (oldest untouched)
182-
183-
assert memo.get("a") == 1
184-
assert memo.get("b") is None
185-
assert memo.get("c") == 3
186-
assert memo.get("d") == 4
179+
tok = object() # get/put require the bound tokenizer's identity
180+
memo.bind_or_reset(tok)
181+
memo.put("a", 1, tokenizer=tok)
182+
memo.put("b", 2, tokenizer=tok)
183+
memo.put("c", 3, tokenizer=tok)
184+
memo.get("a", tokenizer=tok) # touch "a" so it's not the least-recently-used
185+
memo.put("d", 4, tokenizer=tok) # should evict "b" (oldest untouched)
186+
187+
assert memo.get("a", tokenizer=tok) == 1
188+
assert memo.get("b", tokenizer=tok) is None
189+
assert memo.get("c", tokenizer=tok) == 3
190+
assert memo.get("d", tokenizer=tok) == 4
187191

188192
def test_get_stats_reports_entry_count(self) -> None:
189193
memo = TokenCountMemo()
190-
memo.put("a", 1)
191-
memo.put("b", 2)
194+
tok = object()
195+
memo.bind_or_reset(tok)
196+
memo.put("a", 1, tokenizer=tok)
197+
memo.put("b", 2, tokenizer=tok)
192198
assert memo.get_stats()["entries"] == 2
193199

194200

@@ -258,3 +264,94 @@ def test_same_tokenizer_keeps_memo_bound(self) -> None:
258264
entries = memo.get_stats()["entries"]
259265
count_messages_memoized(memo, tokenizer, messages)
260266
assert memo.get_stats()["entries"] == entries # no clear, warm hits
267+
268+
269+
class _GatedTokenizer:
270+
"""Additive tokenizer with a fixed per-message count and an interleaving hook.
271+
272+
``per_message`` differs between the two instances so a cross-tokenizer leak
273+
shows up as a wrong number rather than a coincidence.
274+
"""
275+
276+
ADDITIVE_COUNTS = True
277+
REPLY_OVERHEAD = 0
278+
279+
def __init__(self, per_message: int, before_count: object = None) -> None:
280+
self.per_message = per_message
281+
self._before_count = before_count
282+
283+
def count_message(self, message: dict) -> int: # noqa: ARG002
284+
if self._before_count is not None:
285+
self._before_count()
286+
return self.per_message
287+
288+
def count_messages(self, messages: list[dict]) -> int:
289+
return sum(self.count_message(m) for m in messages) + self.REPLY_OVERHEAD
290+
291+
292+
class TestCrossTokenizerRebindRace:
293+
"""The per-session memo is shared by concurrent requests, and the count
294+
fail-open path (``proxy/token_counting._count_offloaded``) hands each
295+
request a *fresh* ``EstimatingTokenCounter``. So request A can be mid-count
296+
under tokenizer A while request B rebinds the memo to tokenizer B. A's
297+
counts must never reach B.
298+
"""
299+
300+
def test_put_after_concurrent_rebind_never_leaks_to_new_binding(self) -> None:
301+
messages = [{"role": "user", "content": "shared prefix message"}]
302+
key = TokenCountMemo.message_hash(messages[0])
303+
canonical = TokenCountMemo.canonical_message(messages[0])
304+
305+
a_is_counting = threading.Event()
306+
b_has_rebound = threading.Event()
307+
308+
def _pause_a() -> None:
309+
# A has already called bind_or_reset and missed the cache; hold it
310+
# here so B's whole rebind+count lands before A's put.
311+
a_is_counting.set()
312+
assert b_has_rebound.wait(timeout=10), "B never rebound"
313+
314+
a = _GatedTokenizer(per_message=7, before_count=_pause_a)
315+
b = _GatedTokenizer(per_message=31)
316+
memo = TokenCountMemo()
317+
318+
a_total: list[int] = []
319+
a_thread = threading.Thread(
320+
target=lambda: a_total.append(count_messages_memoized(memo, a, messages)),
321+
daemon=True,
322+
)
323+
a_thread.start()
324+
assert a_is_counting.wait(timeout=10), "A never started counting"
325+
326+
# B rebinds (clearing A's era) and populates its own count.
327+
b_total = count_messages_memoized(memo, b, messages)
328+
b_has_rebound.set()
329+
a_thread.join(timeout=10)
330+
assert not a_thread.is_alive()
331+
332+
# A's own total stays exact under tokenizer A — the fix degrades A to
333+
# uncached counting, it does not hand A B's numbers.
334+
assert a_total == [7]
335+
assert b_total == 31
336+
337+
# The proof: A's post-rebind put was dropped, so the memo still holds
338+
# only B's count for the shared message and a fresh B read agrees.
339+
assert memo.get(key, canonical, tokenizer=b) == 31
340+
assert count_messages_memoized(memo, b, messages) == 31
341+
342+
def test_get_from_obsolete_binding_misses_instead_of_reading_new_counts(self) -> None:
343+
"""Mirror direction: a stale binding must not read the rebinder's
344+
counts either (the swap guarantee stays symmetric)."""
345+
messages = [{"role": "user", "content": "shared prefix message"}]
346+
key = TokenCountMemo.message_hash(messages[0])
347+
canonical = TokenCountMemo.canonical_message(messages[0])
348+
349+
a = _GatedTokenizer(per_message=7)
350+
b = _GatedTokenizer(per_message=31)
351+
memo = TokenCountMemo()
352+
353+
count_messages_memoized(memo, a, messages)
354+
count_messages_memoized(memo, b, messages) # rebinds to b
355+
356+
assert memo.get(key, canonical, tokenizer=a) is None
357+
assert memo.get(key, canonical, tokenizer=b) == 31

0 commit comments

Comments
 (0)