Spaces:
Running on Zero
Running on Zero
Fix generation request cleanup
#12
by andito HF Staff - opened
- gradio_ui.py +9 -1
- server.py +30 -13
- tests/test_request_cleanup.py +67 -0
gradio_ui.py
CHANGED
|
@@ -33,7 +33,15 @@ async def events(response):
|
|
| 33 |
raw, pending = pending.split("\n\n", 1)
|
| 34 |
for line in raw.splitlines():
|
| 35 |
if line.startswith("data: "):
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
class Studio:
|
|
|
|
| 33 |
raw, pending = pending.split("\n\n", 1)
|
| 34 |
for line in raw.splitlines():
|
| 35 |
if line.startswith("data: "):
|
| 36 |
+
event = json.loads(line[6:])
|
| 37 |
+
if event.get("type") in {"done", "error"}:
|
| 38 |
+
# Gradio can pause this adapter while rendering its
|
| 39 |
+
# final update. Release backend slots before the UI
|
| 40 |
+
# lets the user start another take.
|
| 41 |
+
await response.body_iterator.aclose()
|
| 42 |
+
yield event
|
| 43 |
+
return
|
| 44 |
+
yield event
|
| 45 |
|
| 46 |
|
| 47 |
class Studio:
|
server.py
CHANGED
|
@@ -682,8 +682,8 @@ class Gate:
|
|
| 682 |
class ClientLimits:
|
| 683 |
"""Per-account concurrency and burst limits.
|
| 684 |
|
| 685 |
-
|
| 686 |
-
|
| 687 |
"""
|
| 688 |
|
| 689 |
SLOT_TTL_S = 300
|
|
@@ -692,20 +692,32 @@ class ClientLimits:
|
|
| 692 |
self.active: dict[str, list[float]] = collections.defaultdict(list)
|
| 693 |
self.recent: dict[str, collections.deque[float]] = collections.defaultdict(collections.deque)
|
| 694 |
|
| 695 |
-
def
|
| 696 |
-
"""
|
| 697 |
now = time.time()
|
| 698 |
recent = self.recent[client]
|
| 699 |
while recent and now - recent[0] > RATE_WINDOW_S:
|
| 700 |
recent.popleft()
|
| 701 |
-
slots =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
if len(slots) >= PER_CLIENT_ACTIVE:
|
| 703 |
-
return "
|
| 704 |
if len(recent) >= PER_CLIENT_REQUESTS:
|
| 705 |
wait = int(RATE_WINDOW_S - (now - recent[0])) + 1
|
| 706 |
return f"That's a lot of takes! Give the GPU a breather and try again in {wait}s."
|
| 707 |
-
|
| 708 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
return now
|
| 710 |
|
| 711 |
def release(self, client: str, slot: float) -> None:
|
|
@@ -1138,14 +1150,18 @@ async def generate(body: GenerateBody, identity: Identity = Depends(identify)) -
|
|
| 1138 |
if len(gate.waiting) >= MAX_QUEUE:
|
| 1139 |
raise HTTPException(503, "The stage is packed right now. Try again in a minute.")
|
| 1140 |
await asyncio.to_thread(check_quota, identity)
|
| 1141 |
-
|
| 1142 |
-
if
|
| 1143 |
-
raise HTTPException(429,
|
| 1144 |
|
| 1145 |
payload = engine_request(choice, turn_text, history, body)
|
| 1146 |
take_id = secrets.token_urlsafe(9)
|
| 1147 |
|
| 1148 |
async def stream():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1149 |
ticket = gate.enqueue()
|
| 1150 |
queued_at = time.perf_counter()
|
| 1151 |
try:
|
|
@@ -1162,10 +1178,11 @@ async def generate(body: GenerateBody, identity: Identity = Depends(identify)) -
|
|
| 1162 |
try:
|
| 1163 |
while not waking.done():
|
| 1164 |
if not (await asyncio.wait({waking}, timeout=KEEPALIVE_S))[0]:
|
| 1165 |
-
|
|
|
|
| 1166 |
waking.result()
|
| 1167 |
except asyncio.TimeoutError:
|
| 1168 |
-
yield sse({"type": "error", "message": "
|
| 1169 |
return
|
| 1170 |
finally:
|
| 1171 |
if not waking.done():
|
|
|
|
| 682 |
class ClientLimits:
|
| 683 |
"""Per-account concurrency and burst limits.
|
| 684 |
|
| 685 |
+
Expiration is a final recovery guard. Streams reserve their slot only once
|
| 686 |
+
consumption starts, and release it when their iterator closes.
|
| 687 |
"""
|
| 688 |
|
| 689 |
SLOT_TTL_S = 300
|
|
|
|
| 692 |
self.active: dict[str, list[float]] = collections.defaultdict(list)
|
| 693 |
self.recent: dict[str, collections.deque[float]] = collections.defaultdict(collections.deque)
|
| 694 |
|
| 695 |
+
def check(self, client: str) -> str | None:
|
| 696 |
+
"""Check limits without reserving a slot or recording a new request."""
|
| 697 |
now = time.time()
|
| 698 |
recent = self.recent[client]
|
| 699 |
while recent and now - recent[0] > RATE_WINDOW_S:
|
| 700 |
recent.popleft()
|
| 701 |
+
slots = [t for t in self.active.get(client, []) if now - t < self.SLOT_TTL_S]
|
| 702 |
+
if slots:
|
| 703 |
+
self.active[client] = slots
|
| 704 |
+
else:
|
| 705 |
+
self.active.pop(client, None)
|
| 706 |
if len(slots) >= PER_CLIENT_ACTIVE:
|
| 707 |
+
return f"You already have {PER_CLIENT_ACTIVE} takes in progress. Wait for one to finish."
|
| 708 |
if len(recent) >= PER_CLIENT_REQUESTS:
|
| 709 |
wait = int(RATE_WINDOW_S - (now - recent[0])) + 1
|
| 710 |
return f"That's a lot of takes! Give the GPU a breather and try again in {wait}s."
|
| 711 |
+
return None
|
| 712 |
+
|
| 713 |
+
def admit(self, client: str) -> float | str:
|
| 714 |
+
"""Reserve atomically when a response actually begins streaming."""
|
| 715 |
+
refused = self.check(client)
|
| 716 |
+
if refused:
|
| 717 |
+
return refused
|
| 718 |
+
now = time.time()
|
| 719 |
+
self.recent[client].append(now)
|
| 720 |
+
self.active[client].append(now)
|
| 721 |
return now
|
| 722 |
|
| 723 |
def release(self, client: str, slot: float) -> None:
|
|
|
|
| 1150 |
if len(gate.waiting) >= MAX_QUEUE:
|
| 1151 |
raise HTTPException(503, "The stage is packed right now. Try again in a minute.")
|
| 1152 |
await asyncio.to_thread(check_quota, identity)
|
| 1153 |
+
refused = limits.check(identity.key)
|
| 1154 |
+
if refused:
|
| 1155 |
+
raise HTTPException(429, refused)
|
| 1156 |
|
| 1157 |
payload = engine_request(choice, turn_text, history, body)
|
| 1158 |
take_id = secrets.token_urlsafe(9)
|
| 1159 |
|
| 1160 |
async def stream():
|
| 1161 |
+
slot = limits.admit(identity.key)
|
| 1162 |
+
if isinstance(slot, str):
|
| 1163 |
+
yield sse({"type": "error", "message": slot})
|
| 1164 |
+
return
|
| 1165 |
ticket = gate.enqueue()
|
| 1166 |
queued_at = time.perf_counter()
|
| 1167 |
try:
|
|
|
|
| 1178 |
try:
|
| 1179 |
while not waking.done():
|
| 1180 |
if not (await asyncio.wait({waking}, timeout=KEEPALIVE_S))[0]:
|
| 1181 |
+
elapsed = round(time.perf_counter() - admitted)
|
| 1182 |
+
yield sse({"type": "retrying", "message": f"Connecting to the H200 endpoint… ({elapsed} s)"})
|
| 1183 |
waking.result()
|
| 1184 |
except asyncio.TimeoutError:
|
| 1185 |
+
yield sse({"type": "error", "message": "Could not connect to the H200 endpoint. Try again in a moment."})
|
| 1186 |
return
|
| 1187 |
finally:
|
| 1188 |
if not waking.done():
|
tests/test_request_cleanup.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request leases must not outlive a cancelled or terminal Gradio response."""
|
| 2 |
+
from contextlib import aclosing, AsyncExitStack
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from test_gradio_ui import anyio_backend, studio, browser_request, body, EngineClient, EngineResponse
|
| 8 |
+
import server
|
| 9 |
+
from accounts import Identity, LOCAL
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def identity():
|
| 13 |
+
return Identity(keys=("test",), name="test", entitlement=LOCAL, signed_in=False)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.mark.anyio
|
| 17 |
+
async def test_unstarted_response_does_not_reserve_a_slot(studio):
|
| 18 |
+
response = await server.generate(body(studio), identity())
|
| 19 |
+
try:
|
| 20 |
+
assert not server.limits.active
|
| 21 |
+
assert server.gate.active == 0
|
| 22 |
+
finally:
|
| 23 |
+
await response.body_iterator.aclose()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.mark.anyio
|
| 27 |
+
async def test_payload_failure_cannot_leak_a_slot(studio, monkeypatch):
|
| 28 |
+
def invalid_payload(*args):
|
| 29 |
+
raise ValueError("Invalid reference")
|
| 30 |
+
monkeypatch.setattr(server, "engine_request", invalid_payload)
|
| 31 |
+
with pytest.raises(ValueError, match="Invalid reference"):
|
| 32 |
+
await server.generate(body(studio), identity())
|
| 33 |
+
assert not server.limits.active
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.mark.anyio
|
| 37 |
+
async def test_prepared_responses_still_enforce_concurrency_when_started(studio, monkeypatch):
|
| 38 |
+
monkeypatch.setattr(server, "PER_CLIENT_ACTIVE", 2)
|
| 39 |
+
monkeypatch.setattr(server, "http", EngineClient(EngineResponse()))
|
| 40 |
+
responses = [await server.generate(body(studio), identity()) for _ in range(3)]
|
| 41 |
+
async with AsyncExitStack() as stack:
|
| 42 |
+
streams = [await stack.enter_async_context(aclosing(r.body_iterator)) for r in responses]
|
| 43 |
+
await anext(streams[0])
|
| 44 |
+
await anext(streams[1])
|
| 45 |
+
refused = json.loads((await anext(streams[2])).removeprefix("data: "))
|
| 46 |
+
assert refused["type"] == "error" and "in progress" in refused["message"]
|
| 47 |
+
assert len(server.limits.active[identity().key]) == 2
|
| 48 |
+
assert server.gate.active == 2
|
| 49 |
+
assert not server.limits.active and server.gate.active == 0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@pytest.mark.anyio
|
| 53 |
+
@pytest.mark.parametrize("error", [False, True])
|
| 54 |
+
async def test_slot_is_free_before_terminal_event_reaches_browser(studio, browser_request, monkeypatch, error):
|
| 55 |
+
response = EngineResponse(error=error)
|
| 56 |
+
monkeypatch.setattr(server, "http", EngineClient(response))
|
| 57 |
+
async with aclosing(studio.stage_run(body(studio), None, [], browser_request)) as stream:
|
| 58 |
+
async for result in stream:
|
| 59 |
+
if result[0]["type"] in {"done", "error"}:
|
| 60 |
+
# The browser can click Perform as soon as this update arrives,
|
| 61 |
+
# while Gradio is still processing the outer iterator.
|
| 62 |
+
assert not server.limits.active
|
| 63 |
+
assert server.gate.active == 0
|
| 64 |
+
assert response.closed
|
| 65 |
+
break
|
| 66 |
+
else:
|
| 67 |
+
pytest.fail("No terminal event")
|