Skip to content

[Bugfix] Keep the offline Omni engine alive after a non-fatal request error - #8450

Merged
Gaohan123 merged 12 commits into
vllm-project:mainfrom
fusheng-ji:fix/offline-omni-keep-engine-on-client-error
Oct 26, 2026
Merged

Gaohan123 merged 12 commits into
vllm-project:mainfrom
fusheng-ji:fix/offline-omni-keep-engine-on-client-error

Conversation

@fusheng-ji

@fusheng-ji fusheng-ji commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Fixes #8449.

Omni.generate() in list mode wrapped the whole run in except Exception: self.close(). Any request-scoped failure, including an OmniClientError the orchestrator had already classified as 4xx, therefore shut the engine down, and the next generate() on the same Omni died with janus.ShutDown from AsyncOmniEngine.add_request.

generate() now closes on EngineDeadError, or on another exception when self.errored reports a dead orchestrator. Request-scoped errors still propagate with their status metadata while a healthy engine remains reusable.

_run_generation() tracks active requests as soon as their client state is registered, before add_request(). If submission fails partway through a batch, it aborts and cleans up both earlier requests and the failing request, without including prompts that were never registered. This cleanup applies to both list and generator mode.

Test Plan

CUDA_VISIBLE_DEVICES='' VLLM_TARGET_DEVICE=cpu python -m pytest -q \
  tests/entrypoints/test_omni_entrypoints.py \
  tests/engine/test_async_omni_engine_outputs.py -m 'core_model and cpu' --run-level=core_model

Regression coverage with the existing FakeAsyncOmniEngine:

  • client error (status 400) on the first request: exception carries status_code / error_type, the request is aborted and removed from request_states, shutdown is not called, and a second generate() on the same instance completes;
  • non-fatal server error (RuntimeError, no status): engine stays up, request aborted;
  • fatal ErrorMessage: OmniEngineDeadError propagates and the engine is still closed;
  • partial batch submission failure in both list and generator mode: registered requests are aborted, later prompts are not submitted, state is cleared, and a subsequent generation succeeds;
  • dead orchestrator raising RuntimeError from output polling: the error propagates and the engine is closed.

vLLM Version: 0.31.0 for the current CPU regression run; 0.30.0 for the original GPU reproduction below.

vLLM-Omni Commit: main ee8fdab1

Test Result

  • Current CPU run: 89 passed across tests/entrypoints/test_omni_entrypoints.py and tests/engine/test_async_omni_engine_outputs.py. All three new review regression cases fail on reviewed commit 50075943 and pass with this fix.
  • Original validation on unpatched main: the two "engine stays usable" tests fail and the fatal-path test passes.
  • All applicable pre-commit checks pass except mypy-3.10: its 9 errors are identical to the reviewed commit (none added).

Original GPU validation (not rerun for the review follow-up), 1 x B200, frozen vllm/vllm-openai:v0.30.0 container, tiny-random/Qwen-Image, one Omni instance, 2 steps / 256x256. The rejected request is produced by the admission check from #8448 ({"prompt_ids": [...]} -> OmniClientError 400), so tree A is the #8448 branch alone and tree B is this fix with the same #8448 engine files overlaid:

Step (same Omni instance) tree A: pre-fix omni.py tree B: this fix
1. text prompt 1 image 1 image
2. {"prompt_ids": [...]} OmniClientError 400 OmniClientError 400
3. text prompt janus.ShutDown 1 image
4. text prompt janus.ShutDown 1 image

Scripts and raw logs (validate_offline_close.py, job.sh, result_*.json, log_*.txt) are kept outside git.

AI assistance: Codex and Claude Code were used to trace the error path, draft the change, tests and this description. All changes were reviewed and the tests run by the author.

… error

Omni.generate() (list mode) closed the whole engine after any exception,
including a request-scoped OmniClientError the orchestrator had already
classified as 4xx. The next generate() on the same Omni then failed with
janus.ShutDown from AsyncOmniEngine.add_request (vllm-project#8449).

_run_generation already aborts the in-flight requests and records the
failure before re-raising, so close() was not needed for cleanup; it turned a
per-request error into an engine shutdown. AsyncOmni.generate aborts and
re-raises without shutting down, and vllm-project#6334 already dropped the unconditional
close for py_generator=True.

Close only on EngineDeadError (OmniEngineDeadError included), where a stage
or the orchestrator is gone. Every other exception propagates with its
status metadata and the engine stays usable. Regressions cover a client
error followed by a successful generate(), a non-fatal server error, and the
fatal path that still closes.

Signed-off-by: Wenbo Ji <36562829+fusheng-ji@users.noreply.github.com>
@fusheng-ji
fusheng-ji marked this pull request as ready for review October 3, 2026 02:02
@fusheng-ji

Copy link
Copy Markdown
Contributor Author

Self-review

  • Scope. Two except branches in Omni.generate(): EngineDeadError (incl. OmniEngineDeadError) still closes the engine; any other exception propagates with its status metadata and the engine stays up. _run_generation already aborts in-flight requests and records the failure, so no cleanup was lost.
  • Why this is the right boundary. OmniBase._handle_output_message already splits fatal from non-fatal ErrorMessages; AsyncOmni.generate aborts and re-raises without shutting down; [Bugfix] Don't shutdown Engine on py_generator=True #6334 removed the same unconditional close for py_generator=True. This aligns list mode with all three.
  • Tests. 3 new CPU tests on the existing FakeAsyncOmniEngine: client error -> status 400, request aborted and dropped from request_states, no shutdown, second generate() succeeds; non-fatal server error -> engine up; fatal error -> OmniEngineDeadError and shutdown. Module 75 passed; the two keep-alive tests fail on unpatched main.
  • GPU. 1 x B200, tiny-random/Qwen-Image, one Omni instance: pre-fix, both calls after a 400 die with janus.ShutDown; with the fix both generate. Table in the description.
  • Lint. pre-commit passes; mypy error set identical to main.

@vllm-omni-review-bot

Copy link
Copy Markdown

This PR appears to belong to: docs/design/module/profiling.md, docs/design/module/entrypoints.md, docs/design/module/observability.md.

Module owners: @alex-jw-brooks @linyueqian @NickCao

Routing: @alex-jw-brooks via module of the changed files, module named in the PR description, semantic router, CODEOWNERS; @linyueqian via module of the changed files, module named in the PR description, semantic router, CODEOWNERS; @NickCao via module of the changed files, module named in the PR description, semantic router, CODEOWNERS

@fusheng-ji, please review your own changes and leave a short self-review comment describing what you checked. PRs without author self-review may not be assigned a reviewer.

Please take a look when you have a chance. If you would like an automated review, mention @vllm-omni-review-bot in a comment.

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot routing record

Assigned Strict on cursor (cursor-grok-4.6-high) under experiment fleet-strict-cursor-grok46-zcode-glm53flash-5050-c5-z10-20261002.

@hsliuustc0106 hsliuustc0106 added bug Something isn't working core related to core module: cache, scheduler, engine, worker, modelrunner labels Oct 4, 2026

@vllm-omni-review-bot vllm-omni-review-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omni ReviewBot review

2 actionable finding(s).

CI at 50075943005b (2026-10-10T05:17:16.068550+00:00): required check(s) blocking: buildkite/vllm-omni (missing).

Full review analysis

Scan:

Category Result
Tests / verification 2 finding(s) below
Security no finding reported
Docs / comments no finding reported
Behavior / compatibility no finding reported
Correctness no finding reported

Validated:

  • [claim-verified] #6334 generator path returns before iteration so generate()'s except never sees runtime errors
  • [claim-verified] status metadata preserved: except Exception re-raises OmniClientError; test asserts status_code==400 and error_type.
  • [claim-refuted] “_run_generation already aborts so close() is unused”: abort is if "active_reqs" in locals() (omni.py:221-225) and active_reqs is assigned at 180 after the add_request loop.
  • [validated] vllm_omni/entrypoints/omni.py:15 from vllm.v1.engine.exceptions import EngineDeadError is required for except EngineDeadError at omni.py:99 so OmniEngineDeadError (omni_base.py:52) and raw EngineDeadError both close; same import as omni_base.py:19 and async_omni_base.py:24; not a new dependency.
  • [validated] pytestmark=[core_model,cpu] at tests/entrypoints/test_omni_entrypoints.py:37; L1 runs pytest tests/entrypoints tests/engine -m 'core_model and cpu' (.buildkite/cuda/test-ready.yml:47)
  • [validated] _client_error_then_outputs 1489-1492: first generate submitted==1, reuse >1; both arms reachable. submitted[0] is the single ["blocked"] request.

2 actionable finding(s).

Verdict: REQUEST CHANGES

See inline comments below.


🤖 This review was generated by InferMatrix Copilot, an open-source repo-maintenance agent for PR review, CI debugging and issue triage. Try it on your own repo, and ⭐ star it if it helped!

logger.exception("[Omni] Engine died during generation: %s", e)
self.close()
raise
except Exception as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This PR removes self.close() from Omni.generate()'s except Exception (omni.py:1…

Evidence and suggested fix

This PR removes self.close() from Omni.generate()'s except Exception (omni.py:105-110) and claims in-flight requests were already aborted in _run_generation. That abort (omni.py:221-225 if "active_reqs" in locals() and active_reqs) only runs after omni.py:180 active_reqs = set(request_ids), which is after every add_request returns. request_states are registered at omni.py:160 before add_request at omni.py:169; a successful earlier add_request already did request_queue.sync_q.put(msg). A later prompt whose process_inputs fails (async_omni_engine.py:352-357, unchanged) therefore leaves earlier prompts in request_states and on the orchestrator queue of the now-kept-alive engine. Victim is generate([ok, bad]), not the single-prompt ErrorMessage path the new tests cover. Initialize active_reqs before the submit loop or abort leftover request_states in this except, and add that batch test.

Evidence: Trigger: Omni.generate([ok, bad]) where the second engine.add_request raises in process_inputs. omni.py:105-110 except Exception as e: / logger.exception("[Omni] Failed to run generation: %s", e) / raise — this PR dropped self.close() here and comments that in-flight requests were already aborted. That abort is omni.py:221-225 except Exception: / if "active_reqs" in locals() and active_reqs: / self.abort(list(active_reqs)). active_reqs is assigned only after the submit loop: omni.py:180 active_reqs = set(request_ids). Each prompt is registered first: omni.py:160 self.request_states[req_id] = req_state then omni.py:169 self.engine.add_request(. Unchanged by this diff, present in the PR-time tree: a successful add_request enqueues the request (async_omni_engine.py:576 self.request_queue.sync_q.put(msg)) before a later prompt's process_inputs re-raises (async_omni_engine.py:352-357 except Exception: ... raise). Adverse effect: active_reqs is never bound so abort is skipped; the first prompt remains in request_states and on the live orchestrator queue, occupying the engine this PR now keeps up. New tests only cover single-prompt ErrorMessage after the wait loop.

Suggestion: except Exception as e:
# Request-scoped failure. Abort leftovers from a submit-loop
# raise (active_reqs is assigned only after every add_request).
logger.exception("[Omni] Failed to run generation: %s", e)
leftover = list(self.request_states)
if leftover:
self.abort(leftover)
raise

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6dddd4.

Thanks, confirmed. _run_generation() now initializes active_reqs before the try block and adds each request immediately when its ClientRequestState is registered, before sampling-parameter preparation or add_request(). A submission exception therefore aborts and removes all registered requests from that batch, including the failing request; prompts that were never registered are not aborted. Cleanup stays in _run_generation() so it also covers generator mode.

Added a regression test for ["ok", "bad", "not submitted"], parametrized over list and generator mode. It checks that the two registered requests are aborted, the third is never submitted, request_states is empty, the healthy engine is not shut down, and a subsequent generation succeeds. Both variants fail on the reviewed commit because no abort occurs.

Validation after the fix: 89 CPU tests passed across tests/entrypoints/test_omni_entrypoints.py and tests/engine/test_async_omni_engine_outputs.py (vLLM 0.31.0). All applicable pre-commit checks passed except the same 9 pre-existing mypy errors; no new type errors.

logger.exception("[Omni] Engine died during generation: %s", e)
self.close()
raise
except Exception as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This diff removes self.close() from except Exception and only closes on `En…

Evidence and suggested fix

This diff removes self.close() from except Exception and only closes on EngineDeadError. List-mode Omni.generate() polls self.engine.try_get_output() (omni.py:187). On an empty queue with a dead orchestrator that API raises RuntimeError("Orchestrator died unexpectedly. See logs above.") (omni_engine_base.py:953-955), not EngineDeadError, so the new keep-alive path skips teardown. The next generate() then talks to a dead orchestrator, and stage processes are not released until GC/finalizer. Omni.errored is already not self.engine.is_alive() (omni_base.py:321). Close when self.errored in this except Exception branch, and add a CPU test that raises this RuntimeError from try_get_output and asserts shutdown_called (the new fatal test only covers ErrorMessage.fatal; FakeAsyncOmniEngine.try_get_output returns None on empty and never raises).

Evidence: Trigger: list-mode Omni.generate() consumes _run_generation, which polls try_get_output until requests finish. If the orchestrator thread dies with an empty output queue, that poll raises RuntimeError, not EngineDeadError; this PR's except Exception then re-raises without close(), so later generate() reuses the dead engine and stage GPU processes are not shut down. omni.py:187 (unchanged by this diff, present in the PR-time tree) msg = self.engine.try_get_output(). omni.py:99-110 (this diff) except EngineDeadError as e: … self.close() / raise then except Exception as e: … raise with no close(). Unchanged by this diff, present in the PR-time tree: omni_engine_base.py:953-955 except queue.Empty: / if not self.is_alive(): / raise RuntimeError("Orchestrator died unexpectedly. See logs above."). Unchanged: omni_base.py:321 return not self.engine.is_alive(). Unmet coverage: tests/entrypoints/test_omni_entrypoints.py:1550-1563 only drives ErrorMessage.fatal → EngineDeadError; FakeAsyncOmniEngine.try_get_output at tests/entrypoints/test_omni_entrypoints.py:162-166 except queue.Empty: / return None and never raises this RuntimeError. tests/engine/test_async_omni_engine_outputs.py:91-92 already pins the engine contract: with pytest.raises(RuntimeError, match="Orchestrator died unexpectedly"): engine.try_get_output().

Suggestion: except EngineDeadError as e:
# Fatal: a stage or the orchestrator is gone, so the engine cannot
# serve anything else. Release the processes now.
logger.exception("[Omni] Engine died during generation: %s", e)
self.close()
raise
except Exception as e:
# Request-scoped failure (e.g. OmniClientError -> 4xx). The in-flight
# requests were already aborted inside _run_generation; keep the
# engine alive so later generate() calls work, like AsyncOmni.
logger.exception("[Omni] Failed to run generation: %s", e)
if self.errored:
self.close()
raise

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6dddd4.

Thanks, confirmed. The generic exception handler now checks self.errored and closes the engine when the orchestrator is dead, while preserving the original exception. Healthy engines still remain reusable after request-scoped errors, and the existing EngineDeadError shutdown branch is retained.

Added a CPU regression test whose try_get_output() marks the orchestrator dead and raises RuntimeError("Orchestrator died unexpectedly. See logs above."). It checks that the error propagates, shutdown is called, and the in-flight request is aborted and removed. This test fails on the reviewed commit because shutdown is not called.

Validation after the fix: 89 CPU tests passed across tests/entrypoints/test_omni_entrypoints.py and tests/engine/test_async_omni_engine_outputs.py (vLLM 0.31.0). All applicable pre-commit checks passed except the same 9 pre-existing mypy errors; no new type errors.

@vllm-omni-review-bot

Copy link
Copy Markdown

Omni ReviewBot: finding feedback

**[p1] This diff removes self.close() from except Exception and only closes on En…** — vllm_omni/entrypoints/omni.py:105`

See the review for details. If you are the PR author and disagree, react 👎 here; the maintainer will see your disagreement.

@vllm-omni-review-bot

Copy link
Copy Markdown

Omni ReviewBot: finding feedback

[p1] This PR removes self.close() from Omni.generate()'s except Exception (omni.py:1… — vllm_omni/entrypoints/omni.py:105

See the review for details. If you are the PR author and disagree, react 👎 here; the maintainer will see your disagreement.

Signed-off-by: Wenbo Ji <36562829+fusheng-ji@users.noreply.github.com>
@vllm-omni-review-bot

vllm-omni-review-bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Omni ReviewBot triage note

Automated triage of commit e87a6501c0f9 produced:

  • Priority: high. Prompt maintainer attention is suggested.

These are automated triage suggestions only — the final decision belongs to the maintainers.

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (failed; retrying strict/cursor/cursor-grok-4.6-high in 120s (try 2 of 3)).

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (failed; retrying strict/cursor/cursor-grok-4.6-high in 600s (try 3 of 3)).

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (failed; falling back to direct/cursor/auto).

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (Cursor exited with 1: ActionRequiredError: You've hit your usage limit You've saved $3501 on API model usage this month with Ultra. Switch to a different model or set a Spend Limit to continue with Auto. Your usage limits will reset when your monthly cycle ends on 10/20/2026.; retrying direct/cursor).

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (Cursor exited with 1: ActionRequiredError: You've hit your usage limit You've saved $3501 on API model usage this month with Ultra. Switch to a different model or set a Spend Limit to continue with Auto. Your usage limits will reset when your monthly cycle ends on 10/20/2026.; retrying direct/cursor).

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (Cursor exited with 1: ActionRequiredError: You've hit your usage limit You've saved $3501 on API model usage this month with Ultra. Switch to a different model or set a Spend Limit to continue with Auto. Your usage limits will reset when your monthly cycle ends on 10/20/2026.; no fallback: the fallb).

@vllm-omni-review-bot

Copy link
Copy Markdown

Omni ReviewBot: no human activity for 7 days

@fusheng-ji this pull request has had no human commit, comment or review since 2026-10-10. Please confirm the current plan and next step. The author or a maintainer decides whether to change the PR state.

To keep it moving, any one of these is enough: push an update, reply to the open blocker, or post the current plan and timeline.

@tzhouam tzhouam added this to the v0.32.0 milestone Oct 22, 2026
# reusable, but close if the orchestrator has died.
logger.exception("[Omni] Failed to run generation: %s", e)
self.close()
if self.errored:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Prevent errors from an aborted batch from failing the next call

If two requests in a batch enqueue non-fatal ErrorMessages before the first is consumed, the first error aborts the batch, but the second remains in the output queue. Since this branch now preserves the healthy engine, a subsequent generate(["valid prompt"]) consumes that stale error and fails, aborting the new request even though its input is valid.

engine.abort() only enqueues cancellation; it does not drain pending output messages. Meanwhile, _handle_output_message() raises ordinary non-fatal errors without checking whether their request IDs still belong to active request state.

Please discard non-fatal request-scoped errors for requests that have already been removed, while preserving global and fatal errors. Add a regression test where both requests in one batch enqueue errors, then verify that a subsequent valid generation succeeds on the same instance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, confirmed. Fixed in [10a1e3fd9](10a1e3f).

_handle_output_message() now discards non-fatal errors whose request IDs are no longer registered. Fatal errors are checked first; global errors and errors for active requests retain their existing behavior.

Each regression submits two failing requests, then a valid request on the same instance:

Initial batch error Mode Reviewed handler (600045ad5): next valid call Fixed handler
OmniClientError (400) List Fails on stale error Succeeds
OmniClientError (400) Generator Fails on stale error Succeeds
RuntimeError List Fails on stale error Succeeds
RuntimeError Generator Fails on stale error Succeeds

The initial batch still raises and aborts both requests. With the fix, the subsequent valid generation completes without another abort or engine shutdown. Generator results refer to consuming the returned generator.

Validation:

  • 104 CPU tests passed, covering entrypoints, async engine outputs, and multimodal cache miss recovery.
  • Restoring only the reviewed handler makes all five targeted regressions fail: the four cases above and the unknown-request handler test.
  • All applicable changed-file pre-commit checks passed, including mypy and Ruff.

Ignore non-fatal errors for requests no longer registered in the offline frontend, while preserving fatal and global errors. Cover client and server errors in list and generator modes, and document the request error boundary.

Signed-off-by: Wenbo Ji <36562829+fusheng-ji@users.noreply.github.com>

@Gaohan123 Gaohan123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks

@Gaohan123 Gaohan123 added the ready label to trigger buildkite CI label Oct 26, 2026
@Gaohan123
Gaohan123 enabled auto-merge (squash) October 26, 2026 06:59
@Gaohan123

Copy link
Copy Markdown
Collaborator

Please resolve CI failure. Thanks

@vllm-omni-review-bot

vllm-omni-review-bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Omni ReviewBot: superseded

The CI failure noted on 2968f1c5b297 refers to an earlier head; the pull request now points at 9c74c27b498f.

The test constructs AsyncOmni with object.__new__, bypassing request-state initialization. Register the active video request so the non-fatal overflow error is handled for a live request, and assert that the error preserves its request ID.

Signed-off-by: Wenbo Ji <36562829+fusheng-ji@users.noreply.github.com>
auto-merge was automatically disabled October 26, 2026 09:55

Head branch was pushed to by a user without write access

@fusheng-ji

Copy link
Copy Markdown
Contributor Author

Please resolve CI failure. Thanks

Hi @Gaohan123, I’ve fixed the CI test fixture in [9c74c27b4](9c74c27).

Local validation: 234 CPU tests passed, including the previously failing test and the stale-error regressions. All applicable pre-commit checks also passed.

Could you please take another look when you have a chance? Thanks!

@Gaohan123 Gaohan123 added ready label to trigger buildkite CI and removed ready label to trigger buildkite CI labels Oct 26, 2026
@Gaohan123
Gaohan123 enabled auto-merge (squash) October 26, 2026 13:30
@Gaohan123
Gaohan123 merged commit 150bcf5 into vllm-project:main Oct 26, 2026
8 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working core related to core module: cache, scheduler, engine, worker, modelrunner ready label to trigger buildkite CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Offline Omni.generate() closes the engine after a non-fatal client error

5 participants