Skip to content

fix(server): survive MLX decode-eval throws instead of aborting the worker#825

Merged
inureyes merged 3 commits into
mainfrom
fix/issue-822-decode-eval-fallible-boundary
Jul 19, 2026
Merged

fix(server): survive MLX decode-eval throws instead of aborting the worker#825
inureyes merged 3 commits into
mainfrom
fix/issue-822-decode-eval-fallible-boundary

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

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. 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: add try_async_eval (body identical to async_eval, declared -> Result<()> so cxx catches any thrown MLX exception at the FFI boundary), mirroring the existing try_eval.
  • src/server/batch/scheduler.rs: route every decode and chunked-prefill evaluation site through the fallible try_eval / try_async_eval variants and translate an Err into the existing abort_sequence path, 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 on Result).

Test plan

  • try_async_eval_ok_matches_async_eval exercises the fallible boundary's success path (schedules a valid graph, returns Ok, reads back the expected value).
  • Scheduler failure-counter logic covered by pure-function unit tests: reset on success, increment and saturate on failure, threshold inclusivity, guard tripping after consecutive failures, single-success reset.
  • Full CUDA build (make release-cuda) passes; cargo check --lib --features cuda clean (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

…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
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:inference Generation, sampling, decoding (incl. speculative, DRY) status:review Under review labels Jul 19, 2026
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
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Route every batch-scheduler decode/prefill eval through the fallible try_eval / try_async_eval cxx boundary so an MLX C++ throw fails just the affected request(s) instead of std::terminate-ing the worker, guarded by a bounded consecutive-failure shutdown.

Findings Addressed

  • Lookahead prime leaked a speculative KV position on a caught try_async_eval throw (HIGH) — fixed in eb3e8ff.

prime_lookahead_with_input returned None on the new caught-throw path without unwinding the speculative KV position lookahead_forward had already appended per sequence. The caller set decode_lookahead = None (and the steady path committed step n while dropping step n+1), so the next synchronous decode ran on a cache whose KV tail was one position longer than generated_tokens accounted for — a desync that corrupts the sequence(s) or forces a batch-wide re-throw, defeating the request-scoped recovery this change is meant to provide. It is a newly reachable path: the previous infallible async_eval could only hit the caches-vanished None (before any append). Fixed by trimming the one speculative position via the existing apply_lookahead_trim(seq_ids, lookahead_teardown_positions(false)) teardown before bailing, mirroring the stale/bootstrap fallbacks; the caches-vanished ? path is untouched (it returns before any append). All prime paths (bootstrap, stale, finishing re-prime, steady step n+1) funnel through this function, so one fix covers them.

Remaining Items (non-blocking, informational)

Verification

  • All stated requirements implemented — all 10 enumerated eval sites converted (9 try_eval + 1 try_async_eval); no infallible eval/async_eval remains in the scheduler.
  • No placeholder/mock code remaining.
  • Integrated into project code flow — conversions live in the real decode/prefill functions reached via run()decide_action(); shutdown_requested is honored at the loop head; guard drains both active_batch and chunked_prefill_seq without duplicate error events.
  • Project conventions followed — try_*(&x).map_err(...) matches the whisper/audio fallible-eval pattern; reuses the existing abort_sequence / abort_sequence_with_error / apply_lookahead_trim helpers.
  • Existing modules reused where applicable.
  • No unintended structural changes — deferred-init prefill_eval borrow handling is sound; success path is unchanged (same MLX call plus a Result branch).
  • Consecutive-failure guard correct — reset on success, saturating increment on failure, count >= 8 threshold; no off-by-one (unit-tested).
  • Compiles clean — cargo check --lib --features cuda (no Rust warnings) after the fix.

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
@inureyes inureyes added status:done Completed and removed status:review Under review labels Jul 19, 2026
@inureyes
inureyes merged commit 9d2f862 into main Jul 19, 2026
5 checks passed
@inureyes
inureyes deleted the fix/issue-822-decode-eval-fallible-boundary branch July 19, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:inference Generation, sampling, decoding (incl. speculative, DRY) priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(server): catch MLX C++ aborts at the decode FFI boundary so a backend throw fails the request, not the process

1 participant