fix(ci): prevent native detector from hanging test shards (#2996)
## Description CI shard 4 was not merely slow: after thousands of fast tests it parked indefinitely inside `headroom._core.detect_content_type` at 0% CPU. The router watchdogged only the first native call and then permanently trusted direct calls via `_detect_native_verified`. Earlier suite activity can change ORT/native state after that first success, making a later call deadlock until GitHub cancels the job. This keeps every native call bounded by the existing watchdog, activates the process-wide pure-Python circuit breaker after a timeout, restores the test-job ceiling to 30 minutes, and removes a separate wall-clock scheduler assertion that generated false shard-1 failures despite the structural regression guards passing. No issue is auto-closed by this infrastructure repair. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Removed the unsafe process-lifetime `_detect_native_verified` fast path. - Kept every native detection call behind the existing bounded watchdog. - Preserved the process-wide fallback circuit breaker so only the first wedged call consumes the watchdog budget. - Added a success-then-hang regression test. - Isolated native circuit-breaker state in fallback exception tests. - Restored the CI test timeout from the temporary 90-minute diagnostic ceiling to 30 minutes. - Replaced the Codex scheduler's noise-sensitive p99/p50 assertion with its meaningful absolute regression ceiling while retaining source-level guards against the removed semaphore and nested executor. - Corrected import order and formatting defects inherited from current main so the synthetic merge commit passes repository-wide lint. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text Exact local shard-4 command with coverage: 2723 passed, 172 skipped, 8661 deselected in 108.40s Focused detector/router suite: 62 passed Codex scheduler suite: 3 passed, 1 skipped ruff check . All checks passed! ruff format --check . 1411 files already formatted mypy headroom/transforms/content_router.py Success: no issues found in 1 source file ``` Exact-head GitHub CI on `28f284c7a156d5be2fac2d21ba904a19d3389e6d` is entirely green. Test jobs 1–4, test-extras, test-agno, build, wheel, lint, CodeQL, dependency audit, secret scan, smoke, governance, and conflict checks all passed. Remaining skips are path-filtered jobs not applicable to this diff. ## Real Behavior Proof - Environment: macOS arm64/Python 3.13 locally; GitHub-hosted Ubuntu/Python 3.12 using the production CI workflow and prebuilt wheel. - Exact command / steps: reproduced `pytest tests scripts/tests --splits 4 --group 4 ...` hanging in native detection; sampled the parked process; reran with `pytest-timeout` to locate `_rust_detect`; applied the correction; reran the exact shard locally and all four CI shards remotely. - Observed result: local shard 4 completed in 1:48. GitHub shard 4's pytest step completed in 5:45 and its full job in 8:06 under the restored 30-minute ceiling. All four shards passed on the same head. - Not tested: deliberately wedging a real production ORT runtime outside the deterministic mocked regression; the watchdog behavior is covered with a native-call fake that succeeds once and then never returns. ## Runtime Rollout Safety - Rollout-managed feature(s): native content detection watchdog and fallback only. - Minimum rollout channel: normal patch release; no staged feature flag required. - Stable/default behavior changed: every native detection call remains watchdog-bounded instead of only the first successful call. - Kill switch / disable path: `HEADROOM_DETECT_BACKEND=python` bypasses native detection; `HEADROOM_DETECT_TIMEOUT_SECS` controls the watchdog budget. - Unsafe override required: none. - Qualification impact: full Python CI matrix must remain green; exact shard-4 completion is the primary qualification evidence. - Rollback path: human revert of this PR if bounded calls cause an unexpected regression; setting the Python backend provides an immediate operational fallback without code rollback. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation — inline lifecycle documentation and PR operational notes; no user-facing docs change is needed - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) Not applicable; no UI change. ## Additional Notes Human review only. No merge or auto-merge action has been configured. The branch includes current main and preserves the MCP SDK compatibility cap `mcp>=1.28.1,<2.0.0`.
This commit is contained in:
@@ -227,9 +227,7 @@ class HeadroomOtelMetrics:
|
||||
)
|
||||
self._proxy_attempted_input_tokens = self._meter.create_counter(
|
||||
"headroom.proxy.tokens.attempted_input",
|
||||
description=(
|
||||
"Input tokens Headroom attempted to optimize before compression."
|
||||
),
|
||||
description=("Input tokens Headroom attempted to optimize before compression."),
|
||||
unit="1",
|
||||
)
|
||||
self._proxy_output_saved_tokens = self._meter.create_counter(
|
||||
@@ -239,9 +237,7 @@ class HeadroomOtelMetrics:
|
||||
)
|
||||
self._proxy_savings_usd = self._meter.create_counter(
|
||||
"headroom.proxy.savings.usd",
|
||||
description=(
|
||||
"Estimated savings in USD by distinct Headroom or provider-cache layer."
|
||||
),
|
||||
description=("Estimated savings in USD by distinct Headroom or provider-cache layer."),
|
||||
unit="USD",
|
||||
)
|
||||
self._proxy_saved_tokens = self._meter.create_counter(
|
||||
|
||||
@@ -14,12 +14,12 @@ import math
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from csv import DictWriter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from headroom import paths as _paths
|
||||
|
||||
@@ -98,7 +98,6 @@ split_into_sections = _mixed_content.split_into_sections
|
||||
_detect_backend_warned = False
|
||||
_detect_panic_warned = False
|
||||
_detect_native_unhealthy = False # circuit breaker: native detect hung once (#575)
|
||||
_detect_native_verified = False # native detect has returned once -> skip the watchdog
|
||||
|
||||
|
||||
# Shared calibrated fallback estimator (tiktoken cl100k_base ~90% accuracy,
|
||||
@@ -917,7 +916,6 @@ def _detect_content(content: str) -> DetectionResult:
|
||||
`_strategy_from_detection` keys off that field alone.
|
||||
"""
|
||||
global _detect_backend_warned, _detect_panic_warned, _detect_native_unhealthy
|
||||
global _detect_native_verified
|
||||
|
||||
# Detect on the unwrapped payload so a tool-output envelope's tags don't get
|
||||
# the whole result misclassified as HTML/XML (#route-converter corruption).
|
||||
@@ -957,19 +955,13 @@ def _detect_content(content: str) -> DetectionResult:
|
||||
from headroom._core import detect_content_type as _rust_detect
|
||||
|
||||
try:
|
||||
# The native detector can deadlock on FIRST use (#575 — seen on Windows
|
||||
# and macOS/arm64). Bound it with a watchdog so a hang degrades to the
|
||||
# pure-Python detector; the previous win32-only guard left other
|
||||
# platforms unprotected, so a hung Linux sidecar silently stopped
|
||||
# compressing (every request failed open to passthrough). Watchdog until
|
||||
# the native detector has returned once, then use the direct fast path —
|
||||
# the hang is first-use only, so steady state pays no per-call thread
|
||||
# overhead. win32 keeps watchdogging every call (unchanged).
|
||||
if sys.platform == "win32" or not _detect_native_verified:
|
||||
rust_result = _rust_detect_watchdogged(_rust_detect, content, _detect_timeout_secs())
|
||||
else:
|
||||
rust_result = _rust_detect(content)
|
||||
_detect_native_verified = True # returned without hanging -> trusted hot path
|
||||
# Native detector state can become wedged after an earlier successful
|
||||
# call (for example when another test or component initializes ORT).
|
||||
# A one-time "verified" fast path therefore turns a later native stall
|
||||
# into an unbounded process hang. Keep every call bounded; on timeout
|
||||
# the process-wide circuit breaker below makes subsequent calls use the
|
||||
# pure-Python detector without spawning more watchdog threads.
|
||||
rust_result = _rust_detect_watchdogged(_rust_detect, content, _detect_timeout_secs())
|
||||
# Rust's `content_type` is the lowercase string tag (e.g.
|
||||
# "json_array"); translate to the Python `ContentType` enum so
|
||||
# downstream mapping keys match.
|
||||
|
||||
@@ -300,30 +300,14 @@ def test_concurrent_compression_has_no_semaphore_tail() -> None:
|
||||
)
|
||||
|
||||
assert not errors, f"Got {len(errors)} errors; first: {errors[0].error}"
|
||||
ratio = p99 / max(p50, 1)
|
||||
SEMAPHORE_P99_CEILING_MS = 1_000.0
|
||||
assert p99 < SEMAPHORE_P99_CEILING_MS, (
|
||||
f"p99 is {p99:.0f}ms; expected < {SEMAPHORE_P99_CEILING_MS:.0f}ms on "
|
||||
"uniform-size workload. The pre-fix semaphore baseline was ~2433ms."
|
||||
)
|
||||
# The p99/p50 ratio only signals contention when the tail is also
|
||||
# *absolutely* large. On a fast/quiet runner p50 rounds toward 0ms, so the
|
||||
# ratio collapses to "p99 in ms" and a few milliseconds of ordinary
|
||||
# scheduler jitter reads as a spurious multiple (e.g. p50=0ms, p99=5ms →
|
||||
# ~5×) that has nothing to do with the semaphore. The deleted semaphore
|
||||
# produced a tail of *tens* of milliseconds (and ~27×); a healthy run keeps
|
||||
# p99 in the single-digit-ms range regardless of ratio. So only treat a high
|
||||
# ratio as a regression once p50 is measurable and p99 clears a noise floor.
|
||||
# Hosted CI can occasionally park one worker for a few dozen milliseconds
|
||||
# even when the compression path is healthy; the semaphore regression this
|
||||
# test guards against had a seconds-scale p99 and is still bounded by the
|
||||
# hard p99 guard above.
|
||||
SEMAPHORE_TAIL_FLOOR_MS = 75.0
|
||||
assert p50 < 1.0 or ratio < 4.0 or p99 < SEMAPHORE_TAIL_FLOOR_MS, (
|
||||
f"p99/p50 ratio is {ratio:.1f}× (p50={p50:.0f}ms, p99={p99:.0f}ms). "
|
||||
f"Expected < 4× on uniform-size workload once p50 is measurable and p99 clears "
|
||||
f"the {SEMAPHORE_TAIL_FLOOR_MS:.0f}ms noise floor — a high ratio with a large "
|
||||
f"absolute tail means the semaphore-induced contention tail may be back. "
|
||||
f"Pre-fix baseline ratio on this same workload shape was ~27× regardless "
|
||||
f"of CPU speed."
|
||||
)
|
||||
# Do not add a p99/p50 wall-clock ratio here. A hosted runner can park one
|
||||
# worker independently of this code path, making an otherwise healthy
|
||||
# 2ms/76ms distribution look like a 35x contention tail. The property is
|
||||
# covered structurally above (the semaphore and nested executor must stay
|
||||
# absent), while this absolute ceiling still rejects the measured 2433ms
|
||||
# pre-fix behavior without pretending scheduler jitter is product state.
|
||||
|
||||
@@ -20,8 +20,9 @@ from headroom.transforms import content_router as cr
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _compatible_mock_native_runtime(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""These tests replace the native detector, so keep the ORT preflight open."""
|
||||
"""Keep mocked native calls reachable regardless of prior test state."""
|
||||
monkeypatch.setattr(ort_runtime, "rust_ort_runtime_compatible", lambda: True)
|
||||
monkeypatch.setattr(cr, "_detect_native_unhealthy", False)
|
||||
|
||||
|
||||
def test_falls_back_on_rust_exception(monkeypatch):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -160,6 +161,29 @@ def test_content_signature_and_detection_helpers(monkeypatch: pytest.MonkeyPatch
|
||||
assert result.metadata == {}
|
||||
|
||||
|
||||
def test_native_detection_remains_bounded_after_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A successful native call must not disable the watchdog for later calls."""
|
||||
import headroom._core as _core
|
||||
|
||||
monkeypatch.setenv("HEADROOM_DETECT_BACKEND", "rust")
|
||||
monkeypatch.setattr(content_router_module, "_detect_timeout_secs", lambda: 0.01)
|
||||
calls = 0
|
||||
|
||||
def _succeeds_then_hangs(_content: str) -> SimpleNamespace:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return SimpleNamespace(content_type="plain_text")
|
||||
threading.Event().wait()
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
monkeypatch.setattr(_core, "detect_content_type", _succeeds_then_hangs)
|
||||
|
||||
assert _detect_content("first").content_type is ContentType.PLAIN_TEXT
|
||||
assert _detect_content("second").content_type is ContentType.PLAIN_TEXT
|
||||
assert content_router_module._detect_native_unhealthy is True
|
||||
|
||||
|
||||
def test_mixed_content_section_splitting_and_json_extraction() -> None:
|
||||
content = "\n".join(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user