Keep the server generation loop alive when a batched request fails - #1513
rajanshxrma wants to merge 1 commit into
Conversation
Two related fixes for mlx_lm.server dying under concurrent batched load (ml-explore#1505): generate.py: PromptProcessingBatch.extend planted None placeholders for logits_processors, so a request without processors entering the prompt batch alone, followed by a request with processors joining a step later, produced a mixed [None, [...]] list that raised "TypeError: 'NoneType' object is not iterable" in GenerationBatch._step when both sequences moved to generation in the same step. Use empty-list placeholders like filter() already does. server.py: any uncaught exception in the batched path killed Thread-1 (_generate) while ThreadingHTTPServer kept accepting requests that block forever on a queue no worker will ever service, with /health still returning ok. Catch failures per-request in the batch admission path (matching the semantics of the existing _tokenize and _serve_single handlers), fail the in-flight requests and reset the batch generator on a serving failure so the thread keeps serving, and fail fast in generate() if the generation thread is not running.
b9530a0 to
58d38e4
Compare
PromptProcessingBatch.extend planted None placeholders for requests without logits processors, while filter() uses []. A mixed batch [None, [proc]] then crashes _step (for processor in logits_processors[e] => TypeError: NoneType is not iterable). Same invariant violation upstream PR ml-explore#1513 fixes. Use [] placeholders (list-comprehension form to avoid shared-reference aliasing). Sampler placeholders correctly stay None (they fall back to fallback_sampler, never iterated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
I independently validated the deterministic mixed- Environment: Apple M5 Max, macOS 26.5.2, Python 3.12.13, mlx 0.31.2. I used the I ran the same API-level
Result:
I also ran Scope: this independently confirms the |
|
Independent validation for the deterministic mixed- #1598 is stacked on this branch, but #1513 still has no review. Could a maintainer take a look when available? |
|
Independent production reproduction of #1505 on stock mlx-lm The exception is the one #1505 lists verbatim, down to the same number — What happened
Nothing was generated after that. Measured on the still-zombied process, before
py-spy on the zombied PID
One detail worth noting for anyone reading server logs for this failure: a RecurrenceSame exception, same stack, four times:
The These instances are supervised by launchd with How this maps to the PR
Scope of this evidence
Environment: Apple M5 Max, 128 GB, macOS 26.5.2, Python 3.12.13, mlx-lm 0.31.3, Full capture (py-spy dump, log excerpts, probe transcripts, code proof) is |
PromptProcessingBatch.extend planted None placeholders for requests without logits processors, while filter() uses []. A mixed batch [None, [proc]] then crashes _step (for processor in logits_processors[e] => TypeError: NoneType is not iterable). Same invariant violation upstream PR ml-explore#1513 fixes. Use [] placeholders (list-comprehension form to avoid shared-reference aliasing). Sampler placeholders correctly stay None (they fall back to fallback_sampler, never iterated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes #1505 (and one of the concrete crashes reported there).
The problem
As reported in #1505, when
Thread-1 (_generate)dies from any uncaught exception,mlx_lm.serverturns into a zombie:ThreadingHTTPServerkeeps accepting connections, but every request blocks forever inResponseGenerator.generate()on a queue no worker will ever service, in-flight requests never resolve, and/healthstill returns{"status": "ok"}. I reproduced this end-to-end on current main with nothing but two plain HTTP requests (M1, 16GB,Llama-3.2-1B-Instruct-4bit,--prompt-concurrency 2):repetition_penaltyResult:
TypeError: 'NoneType' object is not iterableatgenerate.py:1428, generation thread dead, both requests hang, all subsequent requests hang,/healthstill ok — one of the exact exceptions listed in the issue.Root cause of that TypeError
PromptProcessingBatch.extendusesNoneplaceholders when no sequence so far has logits processors:self.logits_processors = [None][None, [processor]]split().generate()→GenerationBatch._stepiterates entryNone→TypeErrorEverything else in the file treats "no processors" as an empty list (
filter()uses[[]] * len(keep));extendis the one place that violates the invariant. The samplerNoneplaceholders are fine — the use site falls back (self.samplers[e] or self.fallback_sampler) — so those are left untouched.Changes
generate.py: empty-list placeholders inPromptProcessingBatch.extend, consistent withfilter(). This also keeps theany(...)-gated fast path (all-empty still skips the processor loop).server.py: the design fix for server: any uncaught exception in _generate leaves HTTP threads serving while every completion hangs forever #1505 —insert_segments) is now covered by the same per-request exception handling that_tokenizeand_serve_singlealready had, so one bad request fails with an error response instead of taking down the thread. The context is only handed to the client after the request is actually registered in the batch.batch_resultson the next crash.ResponseGenerator.generate()raises immediately if the generation thread is not alive, so any residual way to kill the thread turns into error responses rather than infinite client hangs.This also covers the other exceptions listed in #1505 (
[metal::malloc]resource-limit errors under sustained load, etc.): the batch is dropped, the affected requests get errors, and the server keeps serving instead of bricking.Tests
tests/test_generate.py::test_batch_extend_mixed_logits_processors— deterministic regression test for the mixed-placeholder crash (staggered inserts sized to co-finish prefill); fails withTypeErroron current main, passes with the fix.tests/test_server.py::TestResponseGeneratorResilience— injects aRuntimeErrorintoBatchGenerator.next, asserts the caller receives the exception, the generation thread survives, and a subsequent request completes normally.tests/test_server.py(25) andtests/test_generate.py(27) pass locally on an M1.finish_reason: length, no traceback, follow-up requests fine).