Repository navigation
server: a failed prompt fill leaves the batch and its prompt clean (Windows bad_alloc abort) - #97
Merged
joelteply merged 4 commits intoNov 7, 2026
Conversation
LLAMA_SERVER_TEST_FAIL_CHECKPOINTS fails the next N prompt checkpoint copies with std::bad_alloc at the point the copy allocates; LLAMA_SERVER_TEST_FAIL_PROMPT_FILLS throws from the next N prompt fills after they add their tokens. Both are zero unless set. test_failed_fill.py reproduces the Windows node's abort (2026-11-07): two bad_alloc on slot 0 then slot 1, both released, then GGML_ASSERT(batch.slot_batched || batch.size() == 0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
A slot's prompt fill added its tokens to the batch and to slot.prompt.tokens, then create_checkpoint() allocated the state copy; a std::bad_alloc there reached iterate(), which released the slot. Its tokens stayed in the batch with no slot_batched owner (when every filling slot failed, the next decode aborted on GGML_ASSERT(batch.slot_batched || batch.size() == 0)), and its prompt kept tokens whose memory was never written, so the next request with that prefix reused cells that do not exist. - server_batch::get_mark()/rollback(): the batch's state before a slot adds to it, restored when its fill throws. - The prompt fill is one lambda; its caller rolls back the batch and the slot's prompt to the tokens whose memory is written, then rethrows to iterate(), which reports the error and releases the slot as before. - create_checkpoint() builds the copy aside and keeps it only when complete; a copy that cannot be allocated (std::bad_alloc) is refused and logged, and the fill continues: a later restore from there is cold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…rops the draft The speculative checkpoint (taken before a draft is verified, and on the draft context when a draft starts) allocated the same kind of state copy outside any guard: a std::bad_alloc there released a generating slot mid-reply, and the slot was still in `generating`, so handle_last_sampled_token() then aborted on GGML_ASSERT(task) (server-context.cpp:446). copy_checkpoint() is now the one place a checkpoint's state copy is allocated, for prompt and speculative checkpoints alike. A copy that cannot be allocated is refused by type (std::bad_alloc): the checkpoint is cleared, the refusal is logged, and the caller goes on without it. A prompt checkpoint's restore is cold; a speculative checkpoint's draft is dropped (or not started), so the step decodes without it and the reply is unchanged. Dropped drafts are not counted in n_draft_tokens. LLAMA_SERVER_TEST_FAIL_SPEC_CHECKPOINTS injects the speculative refusal; the test generates on mamba-130m (recurrent: every verified draft is checkpointed) with ngram-simple and checks the faulted reply's tokens equal the clean one's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
server_test_faults read LLAMA_SERVER_TEST_FAIL_* from the environment in every build, so a stray variable could fail a real slot. The faults now compile in only with -DLLAMA_SERVER_TEST_FAULTS=ON (a CMake option, off by default, that defines LLAMA_SERVER_TEST_FAULTS for server-context). Without it server_test_faults::enabled is false, every use sits under `if constexpr (server_test_faults::enabled)` and is discarded, and from_env() reads nothing: a default build carries no fault string or symbol. The server CI builds (server.yml, server-sanitize.yml, server-self-hosted.yml) set the option, the tests README says so, and test_failed_fill.py skips with that reason when the server it runs has no fault code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
joelteply
merged commit Nov 7, 2026
46c6644
into
889580fc/seq-state-across-stream-counts
14 of 19 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The crash
Windows node, 2026-11-07 07:08Z, on exactly 444f386:
bad allocationon slot 0, then on slot 1, right after a checkpoint restore; each slot released; thentools/server/server-context.cpp:3442: GGML_ASSERT(batch.slot_batched || batch.size() == 0) failed, and the process aborted.Mechanism (line numbers at 444f386)
iterate()(:3331) catches anystd::exceptionfrom a slot callback, sends the error and callsslot.release().iterate(prompt_slots, ...), :3726) a slot callsbatch.add()for its tokens (:4197) andslot.prompt.tokens.push_back()(:4202). After that,create_checkpoint()(:4299) puts an empty checkpoint in the slot's list (:2516) and then allocates the state copy (update_tgt, :2525, astd::vector::resize). That allocation is wherestd::bad_allocwas thrown.slot_batched = &slotcomes only after it (:4305). The generate path setsslot_batchedbefore it adds anything (:3593).release()keeps the slot's prompt for reuse, so the next request with that prefix trusts cells that do not exist. Cormac traced this. The test below exercises it: with the batch rollback alone, the retry hitspos_min == -1, but n_past > 0. On a slot whose memory already held an earlier prefix, it would reuse the wrong cache silently.pos/n_tokensset, no data) in the slot's checkpoint list.spec_ckpt.update_dftwhen a draft starts (:3625) andupdate_tgt/update_dftbefore a draft is verified (:3683, :3695). Abad_allocthere releases a generating slot mid-reply. The slot is still ingenerating, sohandle_last_sampled_token()then aborts onGGML_ASSERT(task)inneed_embd()(:418; :446 in the measured build). This is measured below.Fix
server_batch::get_mark()/rollback()(one owner, besideclear()) records the batch's tokens, embd,slot_batchedand alora fields before a slot adds anything, and restores them.fill_prompt, with its body unchanged. Its caller initerate(prompt_slots, ...)catches any throw, rolls the batch back to the mark, cutsslot.prompt.tokensback ton_written, then rethrows.n_writtenis the prompt's length when the text-token loop starts. That is afterkeep_first/seq_rmand after any mtmd chunks (which decode), so it counts only tokens whose memory is written.iterate()still reports the error and releases the slot.prefill_order.served()runs only on success, so a failed fill uses up no service turn.copy_checkpoint(slot, ckpt, kind, parts)is now the only place a checkpoint's state copy is allocated, for prompt and speculative checkpoints alike. A copy that cannot be allocated (std::bad_alloc, a typed catch, no string matching) is refused: the checkpoint is cleared, the refusal is logged,falseis returned, and the caller goes on without it.create_checkpoint): the copy is built in a local and added to the list only when complete. A refusal means a restore from there is cold, which is how a missing checkpoint sidecar is already handled.n_draft_tokens.Test
tools/server/server-test-faults.hholds the test-only faults. They compile in only withcmake -DLLAMA_SERVER_TEST_FAULTS=ON, a CMake option that is off by default and is set only by the server CI builds (server.yml,server-sanitize.yml,server-self-hosted.yml) and the tests README. In any other build,server_test_faults::enabledisfalse, every use sits underif constexpr (server_test_faults::enabled)and is discarded, andfrom_env()reads nothing, so a stray environment variable can never fail a real slot.test_failed_fill.pyskips, saying why, when the server it runs has no fault code. In the test build the faults are read from the environment once at load and are zero unless set:LLAMA_SERVER_TEST_FAIL_CHECKPOINTS=N: the next N prompt checkpoint copies throwstd::bad_alloc, at the point where the copy allocates (insidecopy_checkpoint).LLAMA_SERVER_TEST_FAIL_SPEC_CHECKPOINTS=N: the same for the next N speculative checkpoint copies.LLAMA_SERVER_TEST_FAIL_PROMPT_FILLS=N: the next N prompt fills throw after they have added their tokens.tools/server/tests/unit/test_failed_fill.py:test_a_refused_checkpoint_copy_is_not_a_slot_failure(tinygemma3, sliding window, so its prompts checkpoint; 2 faults; two concurrent completions pinned to slots 0 and 1,--cache-ram 0): both complete with equal content, and/healthreturns 200.test_a_failed_fill_leaves_the_batch_and_its_prompt_clean(tinyllama2, 1 fault, same two-slot setup): exactly one request errors and the other completes. Retrying on the faulted slot returnscache_n == 0and the same content as the good slot.test_a_refused_speculative_checkpoint_drops_the_draft_not_the_reply:speculative decoding will use checkpoints). Speculation is--spec-type ngram-simple.draft_n44 on the clean run).speculative checkpoint refused, and/healthreturns 200.The draft-start copy only runs when there is a draft context whose memory cannot drop a tail. The test covers the verification copy; both go through the same
copy_checkpoint.Measured (macOS M5, CPU-only build
-DGGML_METAL=OFF, 2026-11-07)Gating (5f3b59f). These checks scan every binary and library under
build/binpluslibserver-context.awithstrings -a(forLLAMA_SERVER_TEST_FAIL,test faults:andtest fault: the prompt fill) and withnm -C(forserver_test_faults):LLAMA_SERVER_TEST_FAULTS:BOOL=OFF): 0 fault strings and 0 fault symbols in all of them.test_failed_fill.pygives3 skipped("llama-server was built without -DLLAMA_SERVER_TEST_FAULTS=ON"), even with the variables set.=ON), positive control:libllama-server-impl.dylibandlibserver-context.aeach carry 5 fault strings. The functions are inlined, so there are no symbols.3 passed.3 failed.got exception: std::bad_alloc.std::bad_allocon slot 0, thenGGML_ASSERT(task) failed.The runs below were taken before the gate, with the same tests:
On 5683571 (444f386 + the test hook only), the two fill tests fail. The first is the node's crash, line for line:
The second aborts on the same assert after
test fault: the prompt fill failed after adding its tokenson slot 0.Speculative path, red: on eae7df7 (the fill fix without the shared owner), with a temporary
throw std::bad_alloc()at the unguardedckpt.update_tgt(not committed), the speculative test fails:On eaacc70 (this PR),
3 passed:Mutant check: with the
keep_first(n_written)line removed, the fill test fails. The faulted slot's retry trusts 32 unwritten tokens (n_past = 32, pos_min = -1) and aborts.Neighbouring suites (
-m "not slow"): test_failed_fill, test_completion, test_ctx_shift, test_kv_keep_only_active, test_slot_save, test_basic, test_chat_completion, test_speculative, test_stream, test_vision_api: 144 passed, 2 skipped, 1 failed (test_slot_save_while_slot_is_processing).The test_slot_save flakes are timing, not this change
Two tests sometimes fail on this machine with or without this PR:
test_slot_save_while_slot_is_processing("slot 1 never entered processing state")test_deferred_restore_is_bound_to_its_slot("both slots never processing concurrently")Both poll
/slotsevery 20 ms to see a slot mid-generation. The tiny stories260K model generates hundreds of tokens in well under that window on a fast CPU, so the poll can miss the busy state entirely. The second test even carries a skip for this ("runner too fast to separate the slots"). Neither test throws inside a fill or a checkpoint copy, so neither reaches the changed code. Receipt: on an unmodified 444f386 build on this machine,unit/test_slot_save.pyran 3 times and gave2 failed, 9 passed/10 passed, 1 skipped/2 failed, 9 passed, failing the same two tests with the same messages. This PR's build flips between those outcomes the same way.🤖 Generated with Claude Code
https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo