Fix generation request cleanup

#12
by andito HF Staff - opened
Files changed (3) hide show
  1. gradio_ui.py +9 -1
  2. server.py +30 -13
  3. 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
- yield json.loads(line[6:])
 
 
 
 
 
 
 
 
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
- A slot expires after SLOT_TTL_S even if never released: a stream whose client
686
- left before its body started never runs its cleanup.
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 admit(self, client: str) -> float | str:
696
- """A slot token, or the reason the request is refused."""
697
  now = time.time()
698
  recent = self.recent[client]
699
  while recent and now - recent[0] > RATE_WINDOW_S:
700
  recent.popleft()
701
- slots = self.active[client] = [t for t in self.active[client] if now - t < self.SLOT_TTL_S]
 
 
 
 
702
  if len(slots) >= PER_CLIENT_ACTIVE:
703
- return "One take at a time, please. Wait for the current one to finish."
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
- recent.append(now)
708
- slots.append(now)
 
 
 
 
 
 
 
 
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
- slot = limits.admit(identity.key)
1142
- if isinstance(slot, str):
1143
- raise HTTPException(429, slot)
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
- yield sse({"type": "retrying", "message": "Waiting for the H200 endpoint to wake up…"})
 
1166
  waking.result()
1167
  except asyncio.TimeoutError:
1168
- yield sse({"type": "error", "message": "The H200 endpoint is still starting. Try again in a moment."})
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")