Skip to content

Commit 46d5d68

Browse files
Parideboyclaude
andauthored
fix(proxy): bound HF tokenizer load and offload token counting off event loop (headroomlabs-ai#1738)
## Description Fixes headroomlabs-ai#1701. On Windows, `headroom proxy --anthropic-api-url https://api.deepseek.com/anthropic` froze: the first `/v1/messages` request took ~610s (`optimization_latency_ms=609972`) with only router/lifecycle markers, and afterwards the whole server was a zombie — `/livez`, `/readyz` and `/health` hung until the process was killed. `HEADROOM_DETECT_BACKEND=python` was already set, so this was not the headroomlabs-ai#575/headroomlabs-ai#845 native-detect deadlock. Root cause: DeepSeek model names route to the HuggingFace tokenizer backend (`MODEL_PATTERNS` in `headroom/tokenizers/registry.py`). `HuggingFaceTokenizer` loads lazily, so the registry's construction-time fallback never fires; the first `count_messages` calls `AutoTokenizer.from_pretrained(..., trust_remote_code=True)` — unbounded network downloads/retries — and this ran **synchronously inside the async Anthropic messages handler** (`get_tokenizer(model)` + `tokenizer.count_messages(messages)`), outside the 30s `_run_compression_in_executor` bound. huggingface_hub retry chains on a restricted network easily reach ~10 minutes, blocking the entire asyncio event loop; subsequent on-loop counting kept it pinned. tiktoken got a bounded eager load for the same bug class long ago (headroomlabs-ai#956); the HF backend never did. ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Refactoring (no functional changes) ## Changes Made - `headroom/tokenizers/huggingface.py`: `_load_tokenizer` now tries the local HF cache first (`local_files_only=True`, no network), then bounds the network load with `HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS` (default 10s; `0` disables network loads) on a daemon thread. Timeouts/failures return `None` (cached by `lru_cache`, so the hub is probed at most once per process per tokenizer) and `count_messages` fails open to char-based estimation via the existing `_use_fallback()` path. - `headroom/proxy/handlers/anthropic.py`: new `AnthropicHandlerMixin._count_tokens_offloaded(model, messages)` runs `get_tokenizer` + `count_messages` on the compression executor bounded by `COMPRESSION_TIMEOUT_SECONDS`, failing open to `EstimatingTokenCounter` (downgrade logged once per model). Used in `handle_anthropic_messages` (the issue's hot path, both count sites) and `handle_anthropic_batch_create`; the batch path's inline `anthropic_pipeline.apply()` is now offloaded via `_run_compression_in_executor` (mirrors the headroomlabs-ai#1612 image-compression offload). - `headroom/proxy/handlers/batch.py`: the two remaining inline `openai_pipeline.apply()` calls (`handle_google_batch_create`, `_compress_batch_jsonl`) are offloaded the same way; existing `except` blocks keep the pass-through fail-open semantics. - Tests: `tests/test_huggingface_tokenizer_timeout.py` (cache-first, bounded timeout, failure caching, timeout=0, fail-open estimation), `tests/test_tokenizer_count_offload.py` (wiring guards, runs on `headroom-compress` worker, event loop stays responsive during slow tokenizer work, fail-open), plus `_run_compression_in_executor` stub on the batch test double. ## Testing - [x] All existing tests pass - [x] Added new tests for the changes - [ ] Manual testing performed ``` $ python -m pytest tests/test_huggingface_tokenizer_timeout.py tests/test_tokenizer_count_offload.py tests/test_image_compression_offload.py tests/test_gemini_compression_offload.py tests/test_tokenizers tests/test_proxy_handlers_batch.py -q 50 passed $ ruff check . # No issues found $ ruff format --check . # 1043 files already formatted $ mypy headroom --ignore-missing-imports # 0 errors ``` ## Real Behavior Proof - Environment: Windows 11 Pro (10.0.26200), Python 3.13, local checkout of this branch with the Rust core built. - Exact command / steps: `python -m pytest tests/test_tokenizer_count_offload.py -q` — includes `test_count_tokens_offloaded_keeps_loop_responsive`, which reproduces the issue's mechanism: a tokenizer whose `count_messages` blocks (stand-in for the unbounded `AutoTokenizer.from_pretrained` network load) while an asyncio ticker measures event-loop liveness. Also `python -m pytest tests/test_huggingface_tokenizer_timeout.py -q` with a `from_pretrained` stub that sleeps 60s and `HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS=0.2`. - Observed result: with the fix, the slow count runs on a `headroom-compress` worker thread and the loop keeps ticking (`ticks >= 5`; inline it yields ~0 — the zombie). The 60s-hung HF load unblocks at the 0.2s timeout, falls back to estimation, and the second call returns instantly (failure cached, no re-probe). All 10 new tests pass. - Not tested: live reproduction against `api.deepseek.com` from a network where HF hub downloads stall (the reporter's exact environment); actual HF vocab download timing on a healthy network. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c7665ca commit 46d5d68

6 files changed

Lines changed: 405 additions & 28 deletions

File tree

‎headroom/proxy/handlers/anthropic.py‎

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,48 @@
4141
class AnthropicHandlerMixin:
4242
"""Mixin providing Anthropic API handler methods for HeadroomProxy."""
4343

44+
async def _count_tokens_offloaded(self, model, messages): # noqa: ANN001, ANN201
45+
"""Resolve a tokenizer and count messages off the event loop.
46+
47+
Tokenizer resolution can be expensive on first use (HuggingFace
48+
backends may download vocab files) and counting a full Claude Code
49+
conversation is CPU-bound, so both run on the compression executor
50+
bounded by ``COMPRESSION_TIMEOUT_SECONDS`` (GH #1701: an unbounded
51+
on-loop load froze the whole server). On timeout or error this
52+
fails open to character-based estimation.
53+
54+
Returns:
55+
Tuple of ``(tokenizer, token_count)``. The tokenizer is fully
56+
initialized, so later ``count_messages`` calls on it are pure
57+
CPU work.
58+
"""
59+
from headroom.proxy.helpers import COMPRESSION_TIMEOUT_SECONDS
60+
from headroom.tokenizers import EstimatingTokenCounter, get_tokenizer
61+
62+
def _resolve_and_count(): # noqa: ANN202
63+
tokenizer = get_tokenizer(model)
64+
return tokenizer, tokenizer.count_messages(messages)
65+
66+
try:
67+
return await self._run_compression_in_executor(
68+
_resolve_and_count,
69+
timeout=float(COMPRESSION_TIMEOUT_SECONDS),
70+
)
71+
except Exception as e: # fail open — includes asyncio.TimeoutError
72+
# Log the downgrade once per model, not per request.
73+
fallback_models = getattr(self, "_token_count_fallback_models", None)
74+
if fallback_models is None:
75+
fallback_models = set()
76+
self._token_count_fallback_models = fallback_models
77+
if model not in fallback_models:
78+
fallback_models.add(model)
79+
logger.warning(
80+
f"Token counting for model {model} failed or timed out "
81+
f"({e.__class__.__name__}); falling back to estimation"
82+
)
83+
estimator = EstimatingTokenCounter()
84+
return estimator, estimator.count_messages(messages)
85+
4486
@staticmethod
4587
def _resolve_ccr_workspace(
4688
request: Any,
@@ -469,7 +511,6 @@ async def handle_anthropic_messages(
469511
read_request_json_with_bytes,
470512
)
471513
from headroom.proxy.modes import is_cache_mode, is_token_mode
472-
from headroom.tokenizers import get_tokenizer
473514
from headroom.utils import extract_user_query
474515

475516
start_time = time.time()
@@ -899,9 +940,10 @@ async def _finalize_pre_upstream() -> None:
899940
media_type="application/json",
900941
)
901942

902-
# Count original tokens
903-
tokenizer = get_tokenizer(model)
904-
original_tokens = tokenizer.count_messages(messages)
943+
# Count original tokens off the event loop: first-use tokenizer
944+
# resolution may hit the network (HF download) and counting a full
945+
# conversation is CPU-bound — on-loop it froze the server (#1701).
946+
tokenizer, original_tokens = await self._count_tokens_offloaded(model, messages)
905947

906948
# Enterprise Security: scan request before compression
907949
_security_ctx = None
@@ -1164,7 +1206,9 @@ def should_skip_ccr_request_compression(
11641206
)
11651207
if skip_ccr_request_compression:
11661208
optimized_messages = messages
1167-
optimized_tokens = tokenizer.count_messages(optimized_messages)
1209+
_, optimized_tokens = await self._count_tokens_offloaded(
1210+
model, optimized_messages
1211+
)
11681212
else:
11691213
# Zone 1: Swap cached compressed versions into working copy
11701214
working_messages = comp_cache.apply_cached(messages)
@@ -3034,7 +3078,6 @@ async def handle_anthropic_batch_create(
30343078
from headroom.ccr import CCRToolInjector
30353079
from headroom.proxy.helpers import MAX_REQUEST_BODY_SIZE, _read_request_json
30363080
from headroom.proxy.modes import is_cache_mode
3037-
from headroom.tokenizers import get_tokenizer
30383081
from headroom.utils import extract_user_query
30393082

30403083
start_time = time.time()
@@ -3142,17 +3185,27 @@ async def handle_anthropic_batch_create(
31423185
)
31433186
if is_cache_mode(self.config.mode):
31443187
optimized_messages = messages
3145-
original_tokens = get_tokenizer(model).count_messages(messages)
3188+
_, original_tokens = await self._count_tokens_offloaded(model, messages)
31463189
optimized_tokens = original_tokens
31473190
else:
3148-
result = self.anthropic_pipeline.apply(
3149-
messages=messages,
3150-
model=model,
3151-
model_limit=context_limit,
3152-
context=extract_user_query(messages),
3153-
frozen_message_count=frozen_message_count,
3154-
request_id=request_id,
3155-
**proxy_pipeline_kwargs(self.config),
3191+
from headroom.proxy.helpers import COMPRESSION_TIMEOUT_SECONDS
3192+
3193+
# Offload off the event loop (#1701): an inline apply()
3194+
# blocks every other request for the duration; a timeout
3195+
# here is caught below and passes the item through.
3196+
result = await self._run_compression_in_executor(
3197+
lambda messages=messages, model=model, context_limit=context_limit, frozen_message_count=frozen_message_count: (
3198+
self.anthropic_pipeline.apply(
3199+
messages=messages,
3200+
model=model,
3201+
model_limit=context_limit,
3202+
context=extract_user_query(messages),
3203+
frozen_message_count=frozen_message_count,
3204+
request_id=request_id,
3205+
**proxy_pipeline_kwargs(self.config),
3206+
)
3207+
),
3208+
timeout=COMPRESSION_TIMEOUT_SECONDS,
31563209
)
31573210

31583211
optimized_messages = result.messages

‎headroom/proxy/handlers/batch.py‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from fastapi.responses import Response
1616

1717
from headroom.proxy.auth_mode import classify_client
18-
from headroom.proxy.helpers import extract_tags
18+
from headroom.proxy.helpers import COMPRESSION_TIMEOUT_SECONDS, extract_tags
1919
from headroom.proxy.outcome import RequestOutcome
2020

2121
logger = logging.getLogger("headroom.proxy")
@@ -161,11 +161,18 @@ async def handle_google_batch_create(
161161
)
162162

163163
# Use OpenAI pipeline (similar message format after conversion)
164-
result = self.openai_pipeline.apply(
165-
messages=messages,
166-
model=model,
167-
model_limit=context_limit,
168-
context=extract_user_query(messages),
164+
# Offload off the event loop (#1701): inline apply() blocks
165+
# every other request; timeouts fall to the except below.
166+
result = await self._run_compression_in_executor(
167+
lambda messages=messages, model=model, context_limit=context_limit: (
168+
self.openai_pipeline.apply(
169+
messages=messages,
170+
model=model,
171+
model_limit=context_limit,
172+
context=extract_user_query(messages),
173+
)
174+
),
175+
timeout=COMPRESSION_TIMEOUT_SECONDS,
169176
)
170177

171178
optimized_messages = result.messages
@@ -1078,11 +1085,18 @@ async def _compress_batch_jsonl(self, content: str, request_id: str) -> tuple[li
10781085
if self.config.optimize:
10791086
try:
10801087
context_limit = self.openai_provider.get_context_limit(model)
1081-
result = self.openai_pipeline.apply(
1082-
messages=messages,
1083-
model=model,
1084-
model_limit=context_limit,
1085-
context=extract_user_query(messages),
1088+
# Offload off the event loop (#1701); timeouts fall to
1089+
# the except below and pass the line through.
1090+
result = await self._run_compression_in_executor(
1091+
lambda messages=messages, model=model, context_limit=context_limit: (
1092+
self.openai_pipeline.apply(
1093+
messages=messages,
1094+
model=model,
1095+
model_limit=context_limit,
1096+
context=extract_user_query(messages),
1097+
)
1098+
),
1099+
timeout=COMPRESSION_TIMEOUT_SECONDS,
10861100
)
10871101
compressed_messages = result.messages
10881102
# Use pipeline's token counts for consistency with pipeline logs

‎headroom/tokenizers/huggingface.py‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from __future__ import annotations
88

99
import logging
10+
import os
11+
import threading
1012
from functools import lru_cache
1113
from typing import Any
1214

@@ -103,10 +105,32 @@
103105
}
104106

105107

108+
# Bound the first (network) load of a HuggingFace tokenizer. Without a bound,
109+
# huggingface_hub download retries can block for many minutes (GH #1701: 610s
110+
# on a restricted Windows network). 0 disables network loads entirely.
111+
_LOAD_TIMEOUT_ENV = "HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS"
112+
_LOAD_TIMEOUT_DEFAULT = 10.0
113+
114+
115+
def _load_timeout_secs() -> float:
116+
try:
117+
return float(os.environ.get(_LOAD_TIMEOUT_ENV, _LOAD_TIMEOUT_DEFAULT))
118+
except (TypeError, ValueError):
119+
return _LOAD_TIMEOUT_DEFAULT
120+
121+
106122
@lru_cache(maxsize=16)
107123
def _load_tokenizer(tokenizer_name: str):
108124
"""Load and cache HuggingFace tokenizer.
109125
126+
The first attempt is cache-only (``local_files_only=True``) so a warm
127+
HF cache never touches the network. A cache miss falls through to a
128+
network download bounded by ``HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS``
129+
(default 10s) on a daemon thread — the download itself cannot be
130+
cancelled, but the caller unblocks and falls back to estimation.
131+
Failures are cached by ``lru_cache`` (returns ``None``), so a slow or
132+
offline hub is probed at most once per process per tokenizer.
133+
110134
Args:
111135
tokenizer_name: HuggingFace model/tokenizer name.
112136
@@ -119,10 +143,50 @@ def _load_tokenizer(tokenizer_name: str):
119143
return AutoTokenizer.from_pretrained(
120144
tokenizer_name,
121145
trust_remote_code=True,
146+
local_files_only=True,
122147
)
123-
except Exception as e:
124-
logger.warning(f"Failed to load tokenizer {tokenizer_name}: {e}")
148+
except Exception:
149+
pass # Not in the local cache — try the network below, bounded.
150+
151+
timeout = _load_timeout_secs()
152+
if timeout <= 0:
153+
logger.warning(
154+
f"Tokenizer {tokenizer_name} not in local HF cache and network "
155+
f"loading is disabled ({_LOAD_TIMEOUT_ENV}=0); using estimation"
156+
)
157+
return None
158+
159+
result: list[Any] = []
160+
error: list[BaseException] = []
161+
162+
def _download() -> None:
163+
try:
164+
result.append(
165+
AutoTokenizer.from_pretrained(
166+
tokenizer_name,
167+
trust_remote_code=True,
168+
)
169+
)
170+
except BaseException as e: # noqa: BLE001 — report any failure to the waiter
171+
error.append(e)
172+
173+
thread = threading.Thread(
174+
target=_download,
175+
name=f"headroom-hf-tokenizer-load-{tokenizer_name}",
176+
daemon=True,
177+
)
178+
thread.start()
179+
thread.join(timeout)
180+
if thread.is_alive():
181+
logger.warning(
182+
f"Timed out loading tokenizer {tokenizer_name} after {timeout}s "
183+
f"(set {_LOAD_TIMEOUT_ENV} to adjust); using estimation"
184+
)
185+
return None
186+
if error:
187+
logger.warning(f"Failed to load tokenizer {tokenizer_name}: {error[0]}")
125188
return None
189+
return result[0] if result else None
126190

127191

128192
def get_tokenizer_name(model: str) -> str:
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""HF tokenizer loading must be bounded (GH #1701): AutoTokenizer.from_pretrained
2+
performs unbounded network downloads/retries; called lazily from the proxy's request
3+
path it blocked the event loop for ~10 minutes and zombified the server. The fix
4+
tries the local HF cache first (local_files_only=True), bounds the network attempt
5+
with HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS on a daemon thread, and fails open to
6+
estimation — caching the failure so the hub is probed at most once per process.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
import time
13+
import types
14+
from typing import Any
15+
16+
import pytest
17+
18+
from headroom.tokenizers import huggingface as hf_mod
19+
from headroom.tokenizers.huggingface import HuggingFaceTokenizer, _load_tokenizer
20+
21+
22+
@pytest.fixture(autouse=True)
23+
def _fresh_cache():
24+
_load_tokenizer.cache_clear()
25+
yield
26+
_load_tokenizer.cache_clear()
27+
28+
29+
def _install_fake_transformers(monkeypatch: pytest.MonkeyPatch, from_pretrained) -> None:
30+
fake = types.ModuleType("transformers")
31+
fake.AutoTokenizer = type(
32+
"AutoTokenizer", (), {"from_pretrained": staticmethod(from_pretrained)}
33+
)
34+
monkeypatch.setitem(sys.modules, "transformers", fake)
35+
36+
37+
def test_local_cache_tried_before_network(monkeypatch: pytest.MonkeyPatch) -> None:
38+
calls: list[dict[str, Any]] = []
39+
40+
def fake_from_pretrained(name: str, **kwargs: Any):
41+
calls.append(kwargs)
42+
if kwargs.get("local_files_only"):
43+
raise OSError("not in cache")
44+
return "network-tokenizer"
45+
46+
_install_fake_transformers(monkeypatch, fake_from_pretrained)
47+
monkeypatch.setenv("HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS", "5")
48+
49+
assert _load_tokenizer("some/model") == "network-tokenizer"
50+
assert calls[0].get("local_files_only") is True, "first attempt must be cache-only"
51+
assert not calls[1].get("local_files_only")
52+
53+
54+
def test_cache_hit_never_touches_network(monkeypatch: pytest.MonkeyPatch) -> None:
55+
calls: list[dict[str, Any]] = []
56+
57+
def fake_from_pretrained(name: str, **kwargs: Any):
58+
calls.append(kwargs)
59+
return "cached-tokenizer"
60+
61+
_install_fake_transformers(monkeypatch, fake_from_pretrained)
62+
63+
assert _load_tokenizer("some/model") == "cached-tokenizer"
64+
assert len(calls) == 1
65+
assert calls[0].get("local_files_only") is True
66+
67+
68+
def test_slow_network_load_times_out_and_fails_open(monkeypatch: pytest.MonkeyPatch) -> None:
69+
def fake_from_pretrained(name: str, **kwargs: Any):
70+
if kwargs.get("local_files_only"):
71+
raise OSError("not in cache")
72+
time.sleep(60) # simulates hung huggingface_hub download
73+
return "never"
74+
75+
_install_fake_transformers(monkeypatch, fake_from_pretrained)
76+
monkeypatch.setenv("HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS", "0.2")
77+
78+
start = time.monotonic()
79+
assert _load_tokenizer("slow/model") is None
80+
assert time.monotonic() - start < 5, "load must unblock at the timeout, not the download"
81+
82+
# Failure is cached (lru_cache) — the second call must not re-probe the hub.
83+
start = time.monotonic()
84+
assert _load_tokenizer("slow/model") is None
85+
assert time.monotonic() - start < 0.05
86+
87+
88+
def test_timeout_zero_disables_network_loading(monkeypatch: pytest.MonkeyPatch) -> None:
89+
def fake_from_pretrained(name: str, **kwargs: Any):
90+
if kwargs.get("local_files_only"):
91+
raise OSError("not in cache")
92+
raise AssertionError("network load attempted despite timeout=0")
93+
94+
_install_fake_transformers(monkeypatch, fake_from_pretrained)
95+
monkeypatch.setenv("HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS", "0")
96+
97+
assert _load_tokenizer("offline/model") is None
98+
99+
100+
def test_count_messages_fails_open_to_estimation(monkeypatch: pytest.MonkeyPatch) -> None:
101+
def fake_from_pretrained(name: str, **kwargs: Any):
102+
raise OSError("unavailable")
103+
104+
_install_fake_transformers(monkeypatch, fake_from_pretrained)
105+
monkeypatch.setenv("HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS", "0.2")
106+
107+
counter = HuggingFaceTokenizer("deepseek-chat")
108+
tokens = counter.count_messages([{"role": "user", "content": "hello world" * 50}])
109+
assert tokens > 0 # estimation fallback, no exception, no hang
110+
111+
112+
def test_invalid_timeout_env_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
113+
monkeypatch.setenv("HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS", "not-a-number")
114+
assert hf_mod._load_timeout_secs() == hf_mod._LOAD_TIMEOUT_DEFAULT

0 commit comments

Comments
 (0)