fix(server): survive MLX decode-eval throws instead of aborting the worker#825
Conversation
…orker The batch scheduler decode loop force-evaluated on-device tensors through the infallible mlxcel_core::eval / async_eval wrappers, which cxx compiles as noexcept shims, so any MLX C++ throw reached from decode (the graph-cache abort from #818, an allocation failure, an internal invariant) crossed the FFI boundary uncaught and called std::terminate, killing the whole server and dropping every in-flight request with no request-level error. Add try_async_eval to the cxx bridge (body identical to async_eval, declared -> Result<()> so cxx catches the throw), mirroring the existing try_eval. Route every decode and chunked-prefill evaluation site in src/server/batch/scheduler.rs through the fallible variants and translate an Err into the existing abort_sequence path, so a backend throw fails just the affected sequence(s) and the worker keeps serving the rest. A bounded consecutive-failure guard (MAX_CONSECUTIVE_EVAL_FAILURES = 8, reset on any success) treats a persistently-failing backend as unrecoverable: past the threshold it logs a fatal line, aborts the remaining in-flight sequences, and stops the scheduler loop cleanly instead of spinning. Not every MLX throw is safely recoverable: the graph-cache throw happens at graph capture before any state mutation, so failing just that batch is clean, but an out-of-memory or internal-invariant throw can leave the allocator or device undefined. That is why the guard exists; the goal is a graceful request-scoped failure or a clean shutdown, not pretend-recovery. The success path is unchanged (try_* is the same MLX call plus a branch on Result). Tests: try_async_eval_ok_matches_async_eval exercises the fallible boundary's success path, and the scheduler failure-counter logic (reset on success, increment and saturate on failure, threshold inclusivity, guard tripping, single-success reset) is covered by pure-function unit tests. Closes #822
The #822 decode-eval hardening routed the lookahead prime through the fallible `try_async_eval` boundary, but `prime_lookahead_with_input` returned `None` on a caught throw without unwinding the speculative KV position that `lookahead_forward` had already appended for every sequence. The caller then set `decode_lookahead = None` (and the steady path committed step n while dropping the primed step n+1), so the next synchronous decode tick ran on a cache whose KV tail was one position longer than `generated_tokens` accounted for. That desync corrupts the affected sequence(s) or forces a batch-wide re-throw, defeating the request-scoped recovery this hardening is meant to provide, and it is a new path: the previous infallible `async_eval` could only reach the caches-vanished `None` (before any append). Trim the one speculative position via the existing `apply_lookahead_trim(seq_ids, lookahead_teardown_positions(false))` teardown before bailing, mirroring the stale/bootstrap fallbacks, so the fallback synchronous decode starts from a clean cache. The caches-vanished `?` path is left untouched because it returns before any append occurs. Validated with `cargo check --lib --features cuda` (clean, no Rust warnings). Refs #822
Implementation Review SummaryIntent
Findings Addressed
Remaining Items (non-blocking, informational)
Verification
|
Correct two now-outdated statements about MLX FFI-exception handling that this PR's decode/prefill eval guard invalidates. docs/architecture.md's panic-and-threading-posture note said every MLX C++ FFI exception terminates the process; it now qualifies that against the decode/prefill eval boundary, which is wrapped in `try_eval` / `try_async_eval` and fails the affected request(s) instead, backstopped by the consecutive-failure shutdown guard. docs/CONTINUOUS_BATCHING.md's batched-prefill-transient section said an allocation failure at the cohort eval was an uncatchable throw that aborts the whole server; that eval site (`run_padded_batched_prefill`) is one of the sites this PR routes through `try_eval`, so the note now describes the caught-per-cohort failure and the shutdown-guard backstop instead. Also run `cargo fmt` on the two files the eval-guard change touched (src/server/batch/scheduler.rs, src/server/batch/scheduler_tests.rs); the diffs are pure line-wrap adjustments around the `record_eval_outcome` call sites and the `eval_failures_reached_limit` threshold test, no logic change. No test gap found: `lookahead_teardown_positions` (the pure function eb3e8ff's KV-unwind fix reuses for the finishing-vs-steady teardown count) already has both-branch coverage from #729, and the `try_async_eval_ok_matches_async_eval` / failure-counter tests already added in this PR are coherent, so no additional tests were needed. Validation: - cargo fmt --all -- --check: clean - cargo clippy -p mlxcel-core --lib --features cuda -- -D warnings: clean (only a pre-existing C++17/C++14 pair-passing build note, no Rust warnings) - cargo clippy --lib --features cuda -- -D warnings on the root crate (scheduler.rs) triggered a cold mlxcel-core C++ bridge rebuild, so it was aborted per the finalization instructions; the prior cargo check --lib --features cuda pass stands as scheduler.rs's coverage Refs #822, #825
Summary
The batch scheduler decode loop force-evaluated on-device tensors through the infallible
mlxcel_core::eval/async_evalwrappers, which cxx compiles as noexcept shims, so any MLX C++ throw reached from decode (the graph-cache abort from #818, an allocation failure, an internal invariant) crossed the FFI boundary uncaught and calledstd::terminate, killing the whole server and dropping every in-flight request with no request-level error. This routes the decode/prefill evaluation through a fallible boundary so a backend throw fails just the affected request(s), not the process.What changed
src/lib/mlxcel-core/cpp/mlx_cxx_bridge.{h,cpp}+src/lib/mlxcel-core/src/lib.rs: addtry_async_eval(body identical toasync_eval, declared-> Result<()>so cxx catches any thrown MLX exception at the FFI boundary), mirroring the existingtry_eval.src/server/batch/scheduler.rs: route every decode and chunked-prefill evaluation site through the fallibletry_eval/try_async_evalvariants and translate anErrinto the existingabort_sequencepath, so the worker keeps serving the other sequences. A bounded consecutive-failure guard (MAX_CONSECUTIVE_EVAL_FAILURES = 8, reset on any success) treats a persistently-failing backend as unrecoverable: past the threshold it logs a fatal line, aborts the remaining in-flight sequences, and stops the scheduler loop cleanly instead of spinning.Scope and risk
Not every MLX throw is safely recoverable: the graph-cache throw happens at graph capture before any state mutation, so failing just that batch is clean, but an out-of-memory or internal-invariant throw can leave the allocator or device undefined. That is why the guard exists; the goal is a graceful request-scoped failure or a clean shutdown, not pretend-recovery. The success path is unchanged (
try_*is the same MLX call plus a branch onResult).Test plan
try_async_eval_ok_matches_async_evalexercises the fallible boundary's success path (schedules a valid graph, returnsOk, reads back the expected value).make release-cuda) passes;cargo check --lib --features cudaclean (no Rust warnings). Server decode path runs normally end to end (3 concurrent chat completions complete correctly through the fallible-eval loop, no regression).Closes #822