Skip to content

Studio: free the llama-server slot when a chat stream reaches [DONE] - #7564

Merged
danielhanchen merged 5 commits into
mainfrom
studio-stream-slot-release
Jul 29, 2026
Merged

Studio: free the llama-server slot when a chat stream reaches [DONE]#7564
danielhanchen merged 5 commits into
mainfrom
studio-stream-slot-release

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Jul 28, 2026

Copy link
Copy Markdown
Member

The Chat UI Tests job has been timing out intermittently across many unrelated branches. Digging into the artifacts of one failure, it is a real Studio bug, not a Playwright flake: a chat request that has finished generating keeps its llama-server slot until the HTTP response is torn down, so the next chat request queues behind a generation that is already over. The admission queue has no default timeout (DEFAULT_ADMISSION_QUEUE_TIMEOUT_S = None), so that wait is unbounded.

What the artifacts show

Correlating the Studio request log with the llama-server log from run 30326911000 (offsets line up one-to-one across all six generations):

turn 6 turn 7
POST /v1/chat/completions starts 05:02:26.0902 05:02:26.4586
llama-server task 32 launched 26.094, released 26.182 never launched
POST completes 05:05:26.5157 (180425 ms) 05:05:26.4949 (180036 ms)

llama-server freed its only slot 180 seconds before Studio let go of it. Turn 7 never reached llama-server at all: it sat in _openai_admission_wait_stream_chunks emitting keepalives until the browser was torn down. The event loop served eight other requests in between, so nothing was blocked, and n_slots = 1 in that job.

Turn 6 did reach [DONE]. The screenshot shows its answer rendered and the context counter at 117, and that counter is written from the SSE usage chunk after the streaming loop exits, which only happens on [DONE] or EOF. The body stayed open for 180 s, so it was [DONE].

Why nothing recovered

  • The frontend deliberately does not cancel its reader after a natural [DONE] (chat-api.ts), so the request stays open until navigation.
  • _SameTaskStreamingResponse overrides __call__ and detects disconnects only by send() raising OSError. uvicorn advertises ASGI spec_version 2.3 and its send() returns silently when disconnected, and Starlette gates that OSError path on spec_version >= (2, 4). So under uvicorn the branch cannot fire, and the stock listen_for_disconnect task group that would have fired was replaced.
  • No stall guard covers this. _DEFAULT_STREAM_STALL_TIMEOUT_S bounds gaps between upstream llama-server reads and stops applying once [DONE] is parsed; _openai_compat_stream_stall_timeout() covers only the passthrough proxy; _LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S is a cadence, not a cap. Hence 180 s (the Playwright budget), never 120 s.

Changes

1. Release the admission lease when the decode is genuinely over. The lease was released only in the stream's outer finally, which does not run until the ASGI body is torn down. Three conditions now gate the early release, all three needed:

  • Before the yield, not after. Starlette's stream_response suspends the body iterator across the whole of await send(...), uvicorn's send() awaits flow.drain() once the socket is write-paused, and Starlette never aclose()s a body iterator. So a client that stops reading parks the generator on the yield and anything after it is deferred to GC.
  • Exact equality with the bare success sentinel. _openai_stream_error_sse (:434) also ends in [DONE], but it is yielded from the inner generator's except block ahead of the finally that sets the cancel event, drains the worker and closes gen, so the sync generator can still be parked inside _open_stream's httpx client.
  • Only when the sync generator returned on its own. The cancel path breaks the read loop at :10062 and falls through to stream_completed = True and the plain sentinel at :10156 without ever driving gen to StopIteration, and the finally then skips gen.close(). Pressing Stop is ordinary traffic, so the sentinel alone does not prove llama-server is finished.

release() is idempotent, so the existing finally stays a correct backstop. Applied to both admitted_gguf_stream_chunks and admitted_gguf_tool_stream, which share the queue.

2. Bound the disconnect-watcher stop. _stop_local_disconnect_cancel_watcher ran await watcher with no timeout, inside the stream's finally. asyncio.wait never cancels its argument and never re-raises, so the watcher can be abandoned after a timeout; it is a poll loop holding no resources.

This is the amplification, and it is what makes the failure user-visible. Anyone on a 1-slot backend who sends a second message after a response wedges gets "Generating..." forever with no error, and two further consequences of the response never terminating: the ActiveGeneration tracker stays open, and LlamaKeepWarmMiddleware._finish() never fires, so idle auto-unload stops working.

I have not found what wedges the teardown in the first place. Candidates in that finally are narrow, but I could not reproduce it: 7 clean end-to-end runs of the CI Playwright test locally (including with the stack pinned to 2 cores), 7 sequential and 7 held-open HTTP turns, and several thousand synthetic uvicorn requests all came back clean. It needs the 4-vCPU runner under CPU inference load. Decoupling the slot means the wedge no longer takes the next request down with it.

Tests

tests/test_gguf_stream_slot_release.py drives the real ASGI route with _stop_local_disconnect_cancel_watcher monkeypatched to hang, putting the response in exactly the state the CI job was left in, and a receive that never disconnects, matching the browser. It asserts the slot is free once the [DONE] frame has been sent and that a second caller is admitted immediately.

tests/test_gguf_stream_slot_release_ordering.py covers the three gating conditions, each against the real route:

test covers
test_slot_is_free_before_the_done_frame_reaches_send release must precede the yield
test_error_sentinel_keeps_the_slot_until_the_generator_is_closed error form must not free the slot
test_cancelled_stream_keeps_the_slot_until_the_generator_is_closed cancel path must not free the slot

All four route-level tests fail without the corresponding source change and pass with it, verified by reverting.

  • test_gguf_stream_slot_release.py + ..._ordering.py + admission, stream-cancel and tool-passthrough suites: 344 passed
  • full backend suite under the CI invocation: 10981 passed. The 12 failures are RAG, MCP and secure-tools modules that fail identically on clean main here (missing optional deps and network cases).
  • staging cross-platform CI (ubuntu-latest, macos-14, windows-latest) plus a Playwright studio run.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b5017a9a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/routes/inference.py Outdated
Comment on lines 10250 to 10251
async for chunk in iterator:
yield chunk

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the lease before yielding the terminal chunk

If the ASGI send() call stalls on the [DONE] frame, or another consumer stops iterating as soon as it receives that sentinel, execution remains suspended at yield chunk and never reaches this release; the outer finally also cannot run while the response is wedged. On a one-slot backend this preserves the unbounded admission starvation the change is intended to prevent. Detect and release the safe terminal chunk before yielding it; the tool-stream loop at lines 9714–9717 has the same ordering.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f7fa02b: the release now happens before the yield. Confirmed the ordering matters, and for a broader reason than a wedge: Starlette's stream_response suspends the body iterator across the whole of await send(...), uvicorn's send() awaits flow.drain() once the socket is write-paused, and Starlette never aclose()s a body iterator, so a client that stops reading parks the generator on the yield and everything after it is deferred to GC.

Same ordering applied to the tool-stream loop.

Comment thread studio/backend/routes/inference.py Outdated
Comment on lines +735 to +738
Delta chunks are one JSON line ending in ``}``, so only the two terminal
emitters match: the normal end of stream and ``_openai_stream_error_sse``.
"""
return isinstance(chunk, str) and chunk.rstrip().endswith(_SSE_DONE_LINE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the lease until error cleanup finishes

When local chunk processing fails while the synchronous GGUF generator is still active, _openai_stream_error_sse produces a chunk ending in [DONE], so this predicate causes the outer loop to release the admission lease before the inner generator resumes into its finally, sets the cancel event, drains any worker, and closes gen. A following request can therefore enter llama-server while the failed request still occupies its slot, exceeding the configured parallelism and potentially wedging both requests. Only a successful stream-completion sentinel should trigger the early release; error sentinels must retain the existing cleanup/finally release.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f7fa02b: the predicate is now exact equality against the bare success sentinel, so the error form from _openai_stream_error_sse (routes/inference.py:434) no longer frees the slot. Confirmed your reachability argument: the error chunk is yielded from the inner generator's except block at :10167, ahead of the finally that sets the cancel event, drains the worker and closes gen, so the sync generator can still be parked on a yield inside _open_stream's httpx client.

Your comment also led to a third case: the cancel path breaks the read loop at :10062 and falls through to stream_completed = True and the plain sentinel at :10156 with gen never driven to StopIteration, so exact equality alone was not enough either. Both loops now also gate on a flag set only when the sync generator returned on its own. Regression tests for all three are in tests/test_gguf_stream_slot_release_ordering.py.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: a229a5b943

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 445813b47f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member Author

Pushed 76122a039, a readability pass over the slot-release change with no behaviour change:

  • Inlined _sse_chunk_is_stream_done to chunk == _SSE_DONE_CHUNK at both call sites and dropped the helper. The exact-equality rationale from its docstring now sits as a comment on the gguf_stream_chunks guard, since that is the non-obvious part.
  • _gguf_decode_finished and _tool_decode_finished are plain bools instead of single-key dicts, with nonlocal in the two writer generators.

The dicts were only working around rebinding across the closure. Both flags are bound in openai_chat_completions, written one scope down in gguf_stream_chunks / gguf_tool_stream, and read one scope down in the admitted_* generators, so nonlocal covers it.

Worth noting for review: omitting nonlocal here fails silently rather than raising, because the reader is a different nested function that still closes over the outer binding, so it would just see False forever and never release early. I confirmed the tests catch that by deleting the nonlocal line and re-running: test_real_stream_frees_the_slot_at_done_with_a_wedged_teardown and test_slot_is_free_before_the_done_frame_reaches_send both fail, then pass again once restored.

Tests: 355 passed across the slot-release, admission, stream-cancel and tool-passthrough suites. The one test that targeted the helper directly is removed; the behaviour it guarded is already covered at route level by test_error_sentinel_keeps_the_slot_until_the_generator_is_closed.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 76122a0390

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen
danielhanchen merged commit 9e2fc49 into main Jul 29, 2026
43 of 48 checks passed
@danielhanchen
danielhanchen deleted the studio-stream-slot-release branch July 29, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant