a4113eb994
The contributor's 15-test module pinned encoding details (frozen-arg shapes, postponed-annotation resolution, monkeypatch seams). The two behaviour contracts that matter survive: a 12-request /api/profiles burst admits ONE worker and leaves /api/status responsive on a 2-token pool (this file), and the kanban board read shares one worker per key (test_kanban_read_admission). Both go red when the coalescing wrapper is removed from the route.
69 lines
2.3 KiB
Python
69 lines
2.3 KiB
Python
"""GET /api/profiles coalesces a poll burst before AnyIO threadpool admission (#105179)."""
|
|
import asyncio
|
|
import threading
|
|
from functools import wraps
|
|
|
|
import pytest
|
|
|
|
|
|
async def wait_until(predicate):
|
|
async with asyncio.timeout(3):
|
|
while not predicate():
|
|
await asyncio.sleep(0.005)
|
|
|
|
|
|
def async_test(fn):
|
|
@wraps(fn)
|
|
def run(*args, **kwargs):
|
|
return asyncio.run(fn(*args, **kwargs))
|
|
return run
|
|
|
|
|
|
@async_test
|
|
async def test_profiles_burst_leaves_threadpool_status_responsive(monkeypatch):
|
|
import anyio.to_thread
|
|
import httpx
|
|
from fastapi import FastAPI
|
|
from hermes_cli import profiles as profiles_mod
|
|
from hermes_cli.web_routers import profiles
|
|
from starlette.concurrency import run_in_threadpool
|
|
release = threading.Event()
|
|
calls = []
|
|
|
|
def slow_profiles():
|
|
calls.append(1)
|
|
assert release.wait(3)
|
|
return ["example"]
|
|
|
|
monkeypatch.setattr(profiles_mod, "list_profiles", slow_profiles)
|
|
monkeypatch.setattr(profiles, "_profile_to_dict", lambda p: {"name": p})
|
|
monkeypatch.setattr(profiles, "run_in_threadpool", run_in_threadpool)
|
|
app = FastAPI()
|
|
app.include_router(profiles.router)
|
|
|
|
@app.get("/api/status")
|
|
def status():
|
|
return {"ok": True}
|
|
|
|
limiter = anyio.to_thread.current_default_thread_limiter()
|
|
previous = limiter.total_tokens
|
|
limiter.total_tokens = 2
|
|
try:
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
|
tasks = [asyncio.create_task(client.get("/api/profiles")) for _ in range(12)]
|
|
try:
|
|
await wait_until(lambda: calls)
|
|
await asyncio.sleep(0.05)
|
|
response = await asyncio.wait_for(client.get("/api/status"), 0.5)
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ok": True}
|
|
assert calls == [1]
|
|
assert limiter.borrowed_tokens == 1
|
|
finally:
|
|
release.set()
|
|
responses = await asyncio.gather(*tasks)
|
|
assert all(r.status_code == 200 for r in responses)
|
|
assert all(r.json() == {"profiles": [{"name": "example"}]} for r in responses)
|
|
finally:
|
|
limiter.total_tokens = previous
|