fix(server): guarantee AbortMsg delivery on stream cancellation - #222
fix(server): guarantee AbortMsg delivery on stream cancellation#222Artemowka22 wants to merge 2 commits into
Conversation
stream_with_cancellation reacted to a client disconnect with an unowned asyncio.create_task(abort_user(uid)): request teardown could outrun delivery and a failure inside the task degraded to a never-retrieved-exception warning, so the scheduler kept decoding for a client that was gone. Await the abort inline behind asyncio.shield (a second cancellation cannot kill the delivery task), and make abort_user claim the uid first — exactly one AbortMsg even if cancellation runs twice, and none at all when the stream already finished normally. Found via the freetoken-mlx downstream audit (docs/AUDIT.md, defect 2).
|
Supporting evidence, and a case this PR may not cover: the non-streaming path also keeps generating after the client is gone. Environment: FreeToken git Repro: a non-stream |
Follow-up to the review evidence on FlashML-org#222 (benwilson): the non-streaming path kept generating after the client was gone -- with --max-running-requests 1 an abandoned request is a full outage for its remaining max_tokens (measured repro: ~70 s of dead decode, the next client's first token 61 s late). stream_with_cancellation was the only place a disconnect was observed. Give the plain handlers its non-streaming twin: _await_watching_disconnect() runs the generation drain as a task and polls request.is_disconnected() once a second; when the client goes away it delivers the same shielded abort_user (claim + AbortMsg first, so the drain task's own cleanup cannot swallow the claim), then winds the drain down and answers 499 (client closed request -- for the access log; the wire is dead). Handler cancellation (server shutdown) delivers the abort too, mirroring the streaming path. Covers /v1/chat/completions and each prompt of a non-streaming /v1/completions batch. Requests with request=None (adapter-internal callers) are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the measured repro — that is the other half of the bug, and with The PR now covers it: the non-streaming handlers run the generation drain as a task and poll Tests cover the disconnect-abort for both endpoints and the no-abort happy path. |
…gprobs for chat and legacy completions Upstream FlashML-org#224 at 855650d, merged onto deploy/chatdnp for the PR sweep. Conflicts: engine.py keeps FlashML-org#231's stats readout before the logprobs-aware return; openai_api.py keeps the vision `images` argument and FlashML-org#222's disconnect-watching drain with the logprobs entries added; generation.py keeps FlashML-org#266's marker filter and routes every content delta through FlashML-org#224's _content_delta so the logprobs entries ride the filtered text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
|
Tried on 2 x RTX 6000 Ada (sm_89) serving Qwen3.8-Flash-Next (RadixArk NVFP4) at TP=2, offload backend, merged onto my deploy branch with ten other open PRs, tests run on the box, then put in production. Merged clean, the three unit tests pass, and a Two things this PR's path cannot see on the installed stack (uvicorn 0.52.4, Starlette 1.6.0):
Those two turned out to be red herrings on this stack (a minimal uvicorn + Starlette app delivers the disconnect fine, with or without a What fixed it on my branch (three commits, regression tests added): key the abort's idempotency on a set of aborted uids instead of the maps (the scheduler acks an abort for a uid it no longer has), deliver the abort from |
Summary
FrontendManager.stream_with_cancellationreacts to a client disconnect with a bareasyncio.create_task(self.abort_user(uid))and re-raises. The task has no owner: request teardown can complete before the abort coroutine ever runs, and an exception inside it degrades to a "Task exception was never retrieved" warning. The scheduler then keeps decoding for a client that is gone — under a longmax_tokensthis wastes the GPU for minutes and pins KV pages.Fix (ported from a downstream audit — agisota/freetoken-mlx, docs/AUDIT.md, defect 2):
abort_useridempotent — exactly oneAbortMsgper uid even if the handler runs twice (disconnect + server-side cancel).Test plan
pytest tests/server/test_stream_cancellation.py(new): cancellation delivers exactly one AbortMsg before teardown finishes; double cancellation stays single-shot; normal completion sends no aborttests/server/suite passes