diff --git a/CHANGELOG.md b/CHANGELOG.md index cdfb09ab5..3bdbde6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **ccr:** stop emitting Anthropic request-side retrieval markers on frozen-prefix turns when `headroom_retrieve` injection is deferred, so cache-preserving requests forward original content instead of irrecoverable marker-only payloads ([#1006](https://github.com/chopratejas/headroom/issues/1006)). * **proxy:** route Codex OAuth image generation and edit requests through the ChatGPT Codex image backend, while preserving OpenAI API-key image passthrough ([#1215](https://github.com/chopratejas/headroom/pull/1215)). * **wrap (codex):** keep RTK guidance in the global Codex `AGENTS.md` instead of modifying the shared project `AGENTS.md` ([#1235](https://github.com/chopratejas/headroom/issues/1235)). +* **subscription:** run the transcript token-window scan off the event loop (`asyncio.to_thread`). The subscription tracker's poll loop scanned every `~/.claude/projects/**/*.jsonl` transcript and `json.loads`'d each line inline on the proxy's single asyncio event loop; on large or long-running sessions this took seconds and froze `/health` and every in-flight proxied request — a periodic "wedge" recurring on the poll interval. The scan now runs in a worker thread so the loop stays responsive. * **proxy:** enable SSO credential resolution in the native Bedrock route via the `aws-config` `sso` feature flag, making the credential chain match what `docs/bedrock.md` already documented ([#999](https://github.com/chopratejas/headroom/pull/999)). * **proxy:** route native Bedrock `/model/{id}/converse` requests to the upstream Converse endpoint instead of the hard-coded `/invoke` action — the non-streaming handler now resolves the action from the inbound path, matching the streaming handler ([#999](https://github.com/chopratejas/headroom/pull/999)). * **proxy:** preserve byte-faithful `/v1/messages` forwarding when Anthropic tool arrays are already canonical, and only canonicalize-and-mutate tool lists when sorting changes ordering ([#1042](https://github.com/chopratejas/headroom/issues/1042)). diff --git a/headroom/subscription/tracker.py b/headroom/subscription/tracker.py index 9b6547978..319a44da1 100644 --- a/headroom/subscription/tracker.py +++ b/headroom/subscription/tracker.py @@ -729,8 +729,9 @@ class SubscriptionTracker(QuotaTracker): self._state.mark_error("fetch returned None") return - # Read transcript-based window tokens - window_tokens = _compute_window_tokens_for_snapshot(snapshot) + # Offload off the event loop: this scans every ~/.claude/projects/**/*.jsonl + # transcript and json.loads each line, which can take seconds and block /health. + window_tokens = await asyncio.to_thread(_compute_window_tokens_for_snapshot, snapshot) # Detect anomalies discrepancies = _detect_discrepancies(snapshot, window_tokens) diff --git a/tests/test_subscription_tracker.py b/tests/test_subscription_tracker.py index 395f7d7fe..e315b503e 100644 --- a/tests/test_subscription_tracker.py +++ b/tests/test_subscription_tracker.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +import threading from datetime import timedelta from pathlib import Path from types import SimpleNamespace @@ -178,6 +179,47 @@ async def test_maybe_poll_success_updates_state_and_metrics( assert isinstance(metrics_calls[1], dict) +@pytest.mark.asyncio +async def test_maybe_poll_runs_transcript_scan_off_event_loop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: the transcript scan must run off the event-loop thread, or a + multi-second ~/.claude/projects scan wedges the proxy every poll interval.""" + monkeypatch.setattr(SubscriptionTracker, "_load_persisted_state", lambda self: None) + tracker = SubscriptionTracker() + tracker.notify_active("Bearer live-oauth-token") + + snapshot = _make_snapshot() + + async def fetch_snapshot(token: str | None) -> SubscriptionSnapshot: + return snapshot + + tracker._client = SimpleNamespace(fetch=fetch_snapshot) + + loop_thread_id = threading.get_ident() + seen: dict[str, int] = {} + + def recording_compute(snap: SubscriptionSnapshot) -> WindowTokens: + seen["thread_id"] = threading.get_ident() + return WindowTokens(input=7) + + monkeypatch.setattr(tracker_module, "_compute_window_tokens_for_snapshot", recording_compute) + monkeypatch.setattr(tracker_module, "_detect_discrepancies", lambda snap, tokens: []) + monkeypatch.setattr(tracker, "_persist_state", lambda: None) + monkeypatch.setitem( + sys.modules, + "headroom.observability.metrics", + SimpleNamespace( + get_otel_metrics=lambda: SimpleNamespace(record_subscription_window=lambda state: None) + ), + ) + + await tracker._maybe_poll() + + # The blocking scan ran on a worker thread, not the event-loop thread. + assert seen["thread_id"] != loop_thread_id + + def test_persist_and_load_state_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # PR-G2: ``update_contribution`` polls RTK by default; pin the helper to # 0 so the round-trip is deterministic.