Skip to content

fix(server): make audio synthesis panic-safe in release via panic=unwind with explicit core-thread abort - #383

Merged
inureyes merged 3 commits into
mainfrom
fix/issue-375-release-panic-unwind-core-abort
Jun 21, 2026
Merged

inureyes merged 3 commits into
mainfrom
fix/issue-375-release-panic-unwind-core-abort

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

Release builds set [profile.release] panic = "abort", which defeats the catch_unwind request-isolation backstop PR #374 added to the audio worker (run_guarded). Under abort a synthesis panic does not unwind, so a single bad /v1/audio/speech request aborts the whole server instead of degrading to a per-request error. This flips the release profile to panic = "unwind" so that backstop (and the pipeline stage-isolation catch_unwind) work in the shipped binary, and re-imposes fail-fast explicitly on the core generation worker threads so a broken core invariant still aborts cleanly for a supervised restart.

What changed

  • Cargo.toml: [profile.release] panic = "abort" becomes panic = "unwind", with a comment explaining the decision. This is the only profile that sets panic; no member crate overrides it.
  • src/server/model_worker.rs: adds run_core_thread_or_abort, a narrow catch_unwind + std::process::abort() wrapper that logs without request content, and wraps the two core generation worker thread bodies (the batched worker and the legacy --no-batch sequential worker). The batch scheduler, the DiffusionGemma batch-1 loop, and the disaggregated serving-role loop all run inside those two thread bodies, so the two wrappers cover every core generation path. The wrapped-body diff is large because rustfmt re-indents the moved blocks; git diff --ignore-all-space shows the logical change is just the helper plus the two wrappers.
  • src/server/model_worker_tests.rs: adds a happy-path unit test for run_core_thread_or_abort (a non-panicking body runs to completion and its side effect is observed). The abort path terminates the test runner and is documented as manually/subprocess-verified rather than unit-tested in-process.
  • src/server/audio_worker.rs: adds a comment on worker_survives_engine_panic_and_keeps_serving noting it now represents release-build behavior, because release uses unwind to match the always-unwind test profile. The test is kept unchanged.
  • docs/adr/0003-release-panic-unwind-with-core-thread-abort.md (new) and docs/adr/README.md: record the decision, the rejected options (keep abort and document only; full Kokoro-to-Result refactor as disproportionate), the no-global-abort-hook trap, that cargo test always unwinds, and the residual MLX-FFI std::terminate vector.
  • docs/audio-api.md and docs/architecture.md: short notes that synthesis panics are now contained as per-request errors in release, core generation threads abort on panic by design, and MLX FFI exceptions still terminate.

Design notes / traps respected

  • No global std::panic::set_hook that aborts: the hook runs before unwinding and would kill the audio worker run_guarded and the pipeline stage containment this change exists to restore. Only targeted per-thread catch_unwind + abort wrappers are used. The default panic hook (prints panic + backtrace) is kept.
  • The audio worker run_guarded, the pipeline runtime catch_unwind, and Tokio async request handlers are deliberately left contained and are not wrapped.
  • AssertUnwindSafe in the wrapper is sound because the wrapper aborts (never continues) on a caught panic, so no torn state is observed.

Residual abort vector

An MLX C++ FFI exception thrown through a non-Result cxx op becomes std::terminate, not a Rust panic, so neither the catch_unwind backstops nor the unwind policy intercept it. This change does nothing for that path; it was filed separately as #382 and is referenced in the ADR.

Thread spawn audit

Production long-lived threads found (thread::spawn / thread::Builder), and the disposition of each:

  • src/server/model_worker.rs:188 (batched model worker, core generation): wrapped.
  • src/server/model_worker.rs:714 (legacy sequential model worker, core generation): wrapped.
  • src/server/audio_worker.rs:165 (audio worker): left contained; it has its own run_guarded catch_unwind request isolation, which is exactly the boundary this change restores.
  • src/distributed/pipeline/remote_service.rs:597 (remote pipeline stage service): left contained as a pipeline stage-isolation boundary (outside the src/server / src/lib/mlxcel-core/src audit scope; a stage fault is contained to its request/connection).

src/server/diffusion_worker.rs spawns no thread of its own; run_diffusion_worker_loop runs on the model-worker thread, so it is already covered by the two wrappers. The remaining thread::spawn hits in the tree are #[cfg(test)] stubs and test helpers.

Test plan

  • cargo check --lib --tests
  • cargo check --bins
  • cargo test --lib server::audio_worker (7 passed, including worker_survives_engine_panic_and_keeps_serving)
  • cargo test --lib run_core_thread_or_abort (1 passed)
  • cargo clippy --lib --tests -- -D warnings (clean)
  • cargo fmt --check (clean)
  • python3 scripts/ci/check_cross_repo_refs.py (OK)

Closes #375

The release profile set `panic = "abort"`, which defeats the `catch_unwind` request-isolation backstop PR #374 added to the audio worker (`run_guarded` in src/server/audio_worker.rs). Under abort a synthesis panic does not unwind, so the boundary never runs and the whole server process aborts instead of the offending request degrading to a per-request error. The same setting silenced the pipeline stage-isolation `catch_unwind` in production.

Flip `[profile.release]` to `panic = "unwind"` so those deliberate containment boundaries work in the shipped binary as they already do under `cargo test` (the test harness always unwinds). The unwinding-table size and runtime cost are negligible for an MLX-linked inference server whose decode hot path does not panic.

Unwind alone would let a panic on a core generation thread silently kill that thread and leave the server alive but unable to generate, so re-impose fail-fast explicitly where it is wanted: add `run_core_thread_or_abort`, a narrow `catch_unwind` + `process::abort()` wrapper that logs without request content, and wrap the two core generation worker threads (batched and legacy sequential) in src/server/model_worker.rs. The batch scheduler, the DiffusionGemma batch-1 loop, and the disaggregated serving-role loop all run inside those two thread bodies, so the two wrappers cover every core generation path.

The audio worker `run_guarded` and the pipeline stage boundaries deliberately contain panics and are not wrapped. There is no global abort panic hook on purpose: a hook runs before unwinding and would defeat those backstops. The default panic hook (prints panic + backtrace) is kept.

An MLX C++ FFI exception still becomes `std::terminate` rather than a Rust panic and terminates the process; that residual vector is unchanged here and tracked as issue #382.

Adds a happy-path unit test for `run_core_thread_or_abort` (the abort path terminates the test runner and is verified manually), documents that the existing `worker_survives_engine_panic_and_keeps_serving` test now represents release behavior, and records the decision and rejected options in docs/adr/0003.
@inureyes inureyes added type:security Security vulnerability or fix priority:medium Medium priority area:models Model architectures, weights, loading, metadata area:inference Generation, sampling, decoding (incl. speculative, DRY) status:review Under review labels Jun 21, 2026
inureyes added 2 commits June 21, 2026 21:27
Release `panic = "unwind"` (issue #375) made a panic on the remote pipeline stage service thread (`RemoteStageService::run` in src/distributed/pipeline/remote_service.rs) unwind and silently kill the thread while `serve_remote_pipeline_stage` stays parked on `ctrl_c`, leaving a zombie stage process a supervisor will not restart. Under the previous `panic = "abort"` such a panic aborted the stage cleanly, so the unwind flip was a fail-fast regression for a core inference thread that this PR had not covered.

Relocate `run_core_thread_or_abort` from src/server/model_worker.rs to a shared crate-level module (src/worker_failfast.rs) reachable from both the server workers and the distributed pipeline, generalizing it to forward the body's return value so a recoverable `Err` propagates unchanged and only a panic aborts. Wrap the remote stage service thread with it; the `JoinHandle<Result<()>>` type and the `shutdown()` join handling are unchanged on the non-panic path. The local batched and legacy `--no-batch` workers keep their existing wrapping, no global panic hook is added, and the audio worker `run_guarded` containment is untouched.

Correct the ADR 0003, the Cargo.toml profile comment, and architecture.md, which claimed the pipeline stage "stays contained" with its own `catch_unwind`. There is no production `catch_unwind` under src/distributed/ (the only one is `#[cfg(test)]`). The audio worker is now described as the only deliberately contained boundary, all core inference threads (batched, legacy, and remote stage) fail-fast explicitly through `run_core_thread_or_abort`, and pipeline faults are handled at the coordinator by `Result` propagation plus stage timeout and health probing. The MLX FFI `std::terminate` residual (#382) and the always-unwind `cargo test` notes are kept.
The existing happy-path test exercises the void return case. This adds
a test with a Result-returning body that returns Err, confirming the
wrapper forwards a recoverable error unchanged and does not abort.
@inureyes

Copy link
Copy Markdown
Member Author

PR Finalization

Documentation

All four docs verified against the final design:

No em dashes in any of the new content. No stale or drifted phrasing found.

Tests

  • (existing): exercises the void-return happy path.
  • (added, commit c89d601): confirms a Result-returning body that returns Err is forwarded unchanged, not treated as a panic. Cheap, robust, no flakiness.
  • The abort path is documented as manually verified rather than tested in-process; a subprocess harness would add flakiness for a one-line abort and was correctly left out.
  • : kept unchanged with its comment noting it now represents release behavior.

Lint / Format

  • : clean.
  • : clean.
  • : clean.

running 17 tests
test server::model_provider::model_worker::tests::parse_byte_fallback_token_rejects_non_hex_tokens ... ok
test server::model_provider::model_worker::tests::parse_byte_fallback_token_recognises_hex_tokens ... ok
test server::model_provider::model_worker::tests::parse_byte_fallback_token_rejects_leading_sign ... ok
test server::model_provider::model_worker::tests::run_core_thread_or_abort_forwards_err_without_aborting ... ok
test server::model_provider::model_worker::tests::run_core_thread_or_abort_runs_body_to_completion ... ok
test server::model_provider::model_worker::tests::safe_emit_boundary_stops_before_trailing_replacement_chars ... ok
test server::model_provider::model_worker::tests::build_generation_result_computes_finish_reason_and_generation_split ... ok
test server::model_provider::model_worker::tests::merge_config_stop_tokens_appends_only_missing_ids ... ok
test server::model_provider::model_worker::tests::decode_request_images_rejects_decompression_bomb_header ... ok
test server::model_provider::model_worker::tests::decode_request_images_keeps_valid_images_and_rejects_all_invalid_input ... ok
test server::model_provider::model_worker::tests::token_piece_identifies_byte_fallback_tokens ... ok
test server::model_provider::model_worker::tests::byte_fallback_incomplete_sequence_flushed_at_end_of_stream ... ok
test server::model_provider::model_worker::tests::byte_fallback_single_byte_ascii_emits_immediately ... ok
test server::model_provider::model_worker::tests::byte_fallback_incomplete_then_regular_token_does_not_lose_text ... ok
test server::model_provider::model_worker::tests::byte_fallback_emoji_streams_only_complete_sequences ... ok
test server::model_provider::model_worker::tests::byte_fallback_cjk_streams_only_complete_sequences ... ok
test server::model_provider::model_worker::tests::byte_fallback_mixed_regular_and_cjk_streams_correctly ... ok

test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 3532 filtered out; finished in 0.02s: 17 passed.

running 7 tests
test server::audio_worker::tests::worker_surfaces_loader_failure ... ok
test server::audio_worker::tests::default_engine_reports_unsupported_direction ... ok
test server::audio_worker::tests::zero_request_timeout_falls_back_to_default ... ok
test server::audio_worker::tests::worker_round_trip_runs_mlx_op_and_replies ... ok
test server::audio_worker::tests::worker_survives_engine_panic_and_keeps_serving ... ok
test server::audio_worker::tests::queue_full_returns_queuefull_error ... ok
test server::audio_worker::tests::request_timeout_returns_timeout_error ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 3542 filtered out; finished in 0.92s: 7 passed.

@inureyes inureyes added status:done Completed and removed status:review Under review labels Jun 21, 2026
@inureyes
inureyes merged commit 7297a64 into main Jun 21, 2026
7 checks passed
@inureyes
inureyes deleted the fix/issue-375-release-panic-unwind-core-abort branch June 21, 2026 13:03
@inureyes inureyes self-assigned this Aug 31, 2026
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
…ind with explicit core-thread abort (lablup#383)

* fix(server): make audio synthesis panic-safe in release via unwind

The release profile set `panic = "abort"`, which defeats the `catch_unwind` request-isolation backstop PR lablup#374 added to the audio worker (`run_guarded` in src/server/audio_worker.rs). Under abort a synthesis panic does not unwind, so the boundary never runs and the whole server process aborts instead of the offending request degrading to a per-request error. The same setting silenced the pipeline stage-isolation `catch_unwind` in production.

Flip `[profile.release]` to `panic = "unwind"` so those deliberate containment boundaries work in the shipped binary as they already do under `cargo test` (the test harness always unwinds). The unwinding-table size and runtime cost are negligible for an MLX-linked inference server whose decode hot path does not panic.

Unwind alone would let a panic on a core generation thread silently kill that thread and leave the server alive but unable to generate, so re-impose fail-fast explicitly where it is wanted: add `run_core_thread_or_abort`, a narrow `catch_unwind` + `process::abort()` wrapper that logs without request content, and wrap the two core generation worker threads (batched and legacy sequential) in src/server/model_worker.rs. The batch scheduler, the DiffusionGemma batch-1 loop, and the disaggregated serving-role loop all run inside those two thread bodies, so the two wrappers cover every core generation path.

The audio worker `run_guarded` and the pipeline stage boundaries deliberately contain panics and are not wrapped. There is no global abort panic hook on purpose: a hook runs before unwinding and would defeat those backstops. The default panic hook (prints panic + backtrace) is kept.

An MLX C++ FFI exception still becomes `std::terminate` rather than a Rust panic and terminates the process; that residual vector is unchanged here and tracked as issue lablup#382.

Adds a happy-path unit test for `run_core_thread_or_abort` (the abort path terminates the test runner and is verified manually), documents that the existing `worker_survives_engine_panic_and_keeps_serving` test now represents release behavior, and records the decision and rejected options in docs/adr/0003.

* fix(server): wrap remote pipeline stage thread for fail-fast parity

Release `panic = "unwind"` (issue lablup#375) made a panic on the remote pipeline stage service thread (`RemoteStageService::run` in src/distributed/pipeline/remote_service.rs) unwind and silently kill the thread while `serve_remote_pipeline_stage` stays parked on `ctrl_c`, leaving a zombie stage process a supervisor will not restart. Under the previous `panic = "abort"` such a panic aborted the stage cleanly, so the unwind flip was a fail-fast regression for a core inference thread that this PR had not covered.

Relocate `run_core_thread_or_abort` from src/server/model_worker.rs to a shared crate-level module (src/worker_failfast.rs) reachable from both the server workers and the distributed pipeline, generalizing it to forward the body's return value so a recoverable `Err` propagates unchanged and only a panic aborts. Wrap the remote stage service thread with it; the `JoinHandle<Result<()>>` type and the `shutdown()` join handling are unchanged on the non-panic path. The local batched and legacy `--no-batch` workers keep their existing wrapping, no global panic hook is added, and the audio worker `run_guarded` containment is untouched.

Correct the ADR 0003, the Cargo.toml profile comment, and architecture.md, which claimed the pipeline stage "stays contained" with its own `catch_unwind`. There is no production `catch_unwind` under src/distributed/ (the only one is `#[cfg(test)]`). The audio worker is now described as the only deliberately contained boundary, all core inference threads (batched, legacy, and remote stage) fail-fast explicitly through `run_core_thread_or_abort`, and pipeline faults are handled at the coordinator by `Result` propagation plus stage timeout and health probing. The MLX FFI `std::terminate` residual (lablup#382) and the always-unwind `cargo test` notes are kept.

* test(worker): add Err-forwarding test for run_core_thread_or_abort

The existing happy-path test exercises the void return case. This adds
a test with a Result-returning body that returns Err, confirming the
wrapper forwards a recoverable error unchanged and does not abort.
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) area:models Model architectures, weights, loading, metadata priority:medium Medium priority status:done Completed type:security Security vulnerability or fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(server): make audio synthesis panic-safe in release builds (panic="abort" defeats the AudioWorker catch_unwind backstop)

1 participant