Repository navigation
[Benchmark][Omni-DuplexEval] Survive a stalled close, a frozen reply, and a scoreless judge reply - #9260
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
|
This PR appears to belong to: docs/design/module/benchmarking.md. Module owners: @alex-jw-brooks @Bounty-hunter @NickCao Routing: @alex-jw-brooks via module of the changed files, semantic router, CODEOWNERS; @Bounty-hunter via module of the changed files, semantic router, CODEOWNERS; @NickCao via CODEOWNERS @y-null, 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. |
Bounty-hunter
left a comment
There was a problem hiding this comment.
Summary
This PR bounds the final playback ACK, retries selected Omni-DuplexEval transport stalls once, resets per-attempt latency, and exposes retry counts in sample metadata and the run summary.
Review
Must fix
vllm_omni/benchmarks/patch/patch.py:L268— A result that contains both a server error anddrain_timeout/close_timeoutis still retried._duplex_stall_reason()returns the timeout before consideringresult.error, and_duplex_retryable_stall()accepts every reason other than"no completed response". This contradicts the stated invariant that any recorded server error gets exactly one attempt and can turn a deterministic failure into a pass. Reject every result with a non-emptyerrorbefore classifying transport stalls, and add combination tests for server error + drain/close timeout.vllm_omni/benchmarks/duplex/omni_duplex_eval_runner.py:L512— Bounding onlyacknowledge_playback()does not bound the closing write path. Immediately afterward,RealtimeDuplexClient.close_session(timeout_s=20.0)first awaits an unboundedself.send({"type": "session.close"}); its timeout starts only after that send returns. If the same WebSocket write stall that blocked the ACK persists, the sample can still hang before returning a result or writing artifacts, and the outerasyncio.wait_for()can also wait indefinitely for cancellation cleanup. Bound the whole close/send operation (with a forced transport teardown fallback) and add a regression test whose close send never returns.
Verdict: Must fix before merge; both issues affect the transport-stall behavior this PR is intended to guarantee.
Omni ReviewBot triage noteAutomated triage of commit
These are automated triage suggestions only — the final decision belongs to the maintainers. |
8c7e6ac to
febcf87
Compare
|
Thanks for the detailed review — both points are addressed in febcf87: 1. patch.py — server-error results are no longer retried One note on the literal rule: If you'd rather have the stricter literal rule (and drop stall-retry entirely), say the word and I'll flip it. 2. omni_duplex_eval_runner.py — the whole close/send path is bounded now
Regression test added: a mocked Tests: the new close-stall cases plus the existing retry suite ( Two transparency notes: (a) the forced teardown reaches |
Bounty-hunter
left a comment
There was a problem hiding this comment.
Incremental re-review
Re-reviewed febcf875b58eb2229d53ce06bed496353c896465. The two findings from the previous snapshot are fixed: reported server/response failures now suppress transport retries, and the complete session.close send path is bounded with forced transport teardown.
Review
Must fix
vllm_omni/benchmarks/duplex/omni_duplex_eval_runner.py:L584-L586— The periodic playback-ACK task still has an unbounded cancellation join. If_ack_playback()is already blocked in the same WebSocket write and does not unwind on cancellation,ack_task.cancel(); await ack_tasknever reaches the newly bounded final ACK or_close_duplex_session(), so the outer per-request timeout can still wait forever during cleanup and the sample again produces no artifacts. Bound this join with the same grace/forced-teardown policy and add a regression whereacknowledge_playback()actually suppresses the firstCancelledError. The currentasyncio.shield(never)fake does not exercise that branch: cancelling the outer task completes it immediately while only the inner awaitable remains detached.
Verdict: One transport-stall path remains unbounded and should be fixed before merge.
|
Fixed in 5af675e. The periodic-ACK join is now bounded by The new regression ( |
Bounty-hunter
left a comment
There was a problem hiding this comment.
Closure re-review
Re-reviewed 5af675eb0da31adbd80eff7f2d0554c9b6e8ef1b. The three previously reported transport/retry findings are fixed on this snapshot: deterministic server/response failures are not retried, the complete close send is bounded with forced teardown, and the periodic ACK join now has a bounded grace with a regression test for the original artifact-loss path.
Review
Must fix
vllm_omni/benchmarks/duplex/omni_duplex_eval_runner.py:L600-L603— This handler now swallows cancellation ofgenerate_sample()itself._bounded_ack_join()usesasyncio.wait()and represents a cancelled child ACK task by returning normally, so the onlyCancelledErrorthat can reach thisexceptis an outer cancellation (for example, the benchmark's per-requestasyncio.wait_for()expiring during the grace window). Converting that cancellation intopasslets the request continue through the final ACK and close paths instead of timing out/retrying; a current-source minimal reproduction returns normally withouter_task_cancelled=False. Remove this obsolete handler (or re-raise), and add a regression that cancelsgenerate_sample()while_bounded_ack_join()is waiting and asserts the outer cancellation/timeout still propagates after bounded cleanup.
Verdict: The ACK stall itself is bounded now, but the surrounding cancellation contract still needs this fix. The separate DCO check is also currently failing.
21bc94d to
e4bb0bb
Compare
88190b2 to
f30b107
Compare
Omni ReviewBot: supersededThe CI failure noted on |
f30b107 to
fc6deb1
Compare
baca357 to
adfa5dd
Compare
amy-why-3459
left a comment
There was a problem hiding this comment.
Thanks for splitting this out — the bounded ack/close path, the "retry only a stall that came alone" gate, and the retry tally all read well, and the close-stall tests cover the swallowed-cancellation cases nicely. A few code-level points, mostly around the new silence padding and the judge retry:
-
Silence padding starts before
commit()(omni_duplex_eval_runner.py). The pad task is created beforeawait client.commit(), so its firstappend_audiocan be scheduled during the commit's await and land in the pre-commit buffer. The code just above deliberately shapes the tail ofpcmso a residual model unit is left for the commit to flush; an extra full silence unit appended before the commit changes that residual and therefore what_residual_unit_decidedwaits for. Starting the padding only aftercommit()returns (or afterinput_audio_buffer.committedis observed) would keep the end-of-clip contract intact. -
Padding may keep the drain from settling or open new replies. After the commit, every padded unit is new input the server steps on. If those steps emit events counted by
_completion_activity(events, commit_from),stable_sincekeeps resetting andresponses_finishedcan only end via the drain timeout; and if the model decides to speak during the padded silence, a new response gets created and then scored as part of the sample. It would be good to confirm which events the padded units produce and, if needed, exclude them from the activity count / stop padding once every open response is done. -
No test exercises
_pad_silence_until_stopped. The new tests cover the close/ack stalls, the retry gate and the judge follow-up, but nothing asserts that padding (a) never precedes the commit, (b) stops when the drain finishes, and (c) does not change the residual-unit decision for a clip that already completes normally. A fake client recording the order ofappend_audio/commitcalls would cover (a) and (b) cheaply. -
The judge follow-up is not traced when it raises. In
judge_result, aJudgeResponseErrorfrom the firstcall()is written to*.judge.jsonl, but the same error from the retriedcall(followup=...)propagates without a trace line, and a secondparse_judge_jsonfailure leaves only the retry's raw text. Wrapping the retry the same way (_trace({... "judge_retry": True, "error": ...})) keeps the trace complete for exactly the samples you are trying to diagnose. Also note that forcontent, a failed retry still falls through to theframe-samplefallback, which does its own call + follow-up, so one sample can now issue up to four judge requests (each re-sending frames/video) — worth a line in the docstring or the summary. -
Per-request-timeout retries bypass the deterministic-failure gate (
patch.py,except asyncio.TimeoutError). The returned-result path only retries when there is noserver_errorsentry, but the outer 900 s timeout path retries unconditionally because no result is available. That is probably the intended trade-off now that teardown is bounded, but it also doubles the worst-case wall time of a sample to ~2 ×_omni_request_timeout_s(); if the CI job has an overall budget, it may be worth documenting or capping.
Minor: _duplex_reported_error strips the timeout strings with str.replace, so if a server error message happens to contain the same text it is stripped too — unlikely, but comparing against the individual entries in errors rather than the joined string would be more robust.
yenuo26
left a comment
There was a problem hiding this comment.
Review (tip adfa5dd)
Checked the bounded ack/close/teardown path, stall-only retry gate (server_errors / residual result.error vs timeout strings), drain slack + silence padding, judge format follow-up (chat/temporal/content + **kw lambdas), and the new duplex/patch/judge suites. Earlier Bounty-hunter fixes on server-error+timeout non-retry and bounded close/ACK join are present on this tip.
Findings
-
Open residual from @Bounty-hunter (still on tip) —
omni_duplex_eval_runner.pystill hasexcept asyncio.CancelledError: passaroundawait _bounded_ack_join(ack_task). Because_bounded_ack_joinusesasyncio.waitand returns normally for a cancelled child, the onlyCancelledErrorthat reaches this handler is outer cancellation ofgenerate_sample()(e.g. the per-requestwait_for). Swallowing it lets the sample continue through final ACK/close instead of propagating timeout/retry. Please remove or re-raise, and add the regression Bounty-hunter asked for (cancelgenerate_samplewhile the join is waiting). -
Silence padding starts before
commit()— agreeing with @amy-why-3459: the pad task is created beforeawait client.commit(), so its firstappend_audiocan land in the pre-commit buffer and change the residual unit the commit was shaped to flush. Start padding only aftercommit()(or afterinput_audio_buffer.committed). -
No direct coverage of
_pad_silence_until_stopped— also per amy: please add a cheap fake-client test that padding (a) never precedes commit, (b) stops when the drain finishes, and (c) does not disturb a clip that already completes cleanly. Worth also confirming padded units do not keep resetting_completion_activity/ open a scored late reply. -
Judge retry trace incomplete on failure — a
JudgeResponseError(or secondparse_judge_jsonmiss) on the follow-up call is not_traced the way the first attempt is; please mirror the first-attempt tracing withjudge_retry: True. Soft: note in a docstring/summary that a content call can fall through to frame-sample and issue up to four judge requests.
Soft / already noted by peers (no need to block on these alone)
- Outer
asyncio.TimeoutErrorretries bypass the deterministic-failure gate and can ~2× worst-case wall time — document or cap if CI budgets care. _duplex_reported_errorstr.replaceof timeout text is slightly fragile if a server error embeds the same substring.- Final
ack_timeout = str(exc)often records""for bareasyncio.TimeoutError; a short sentinel would make*.meta.json/ logs diagnosable.
Not blocking on this pass: UT placement under tests/benchmarks/{duplex,patch}/ matches the component; no Buildkite YAML / redundant tee; no hardcoded bind ports (URL strings only).
Happy to re-check once (1)–(3) land.
adfa5dd to
c7d5be3
Compare
|
Addressed all must-fix points in 1. Padding started before 2. Padded units vs the activity window (amy 2) — the units are non-speech and the server does not commit them, so they add no 3. No coverage of the padding (amy 3, yenuo 3) — added
A clip that already completes cleanly is unaffected: nothing is appended before the commit, and after it only non-speech units are added. 4. Judge retry tracing (amy 4, yenuo 4) — fixed, keeping one trace record per judge request (the existing 5. Timeout path retries unconditionally (amy 5) — documented in place: a timed-out attempt leaves no result to inspect, so it is retried once, worst case ~2x the per-request timeout. Left uncapped: the teardown is bounded now, and letting one sample take twice as long is cheaper than reporting it as a failure. Minor:
CancelledError (open Bounty-hunter residual / yenuo 1) — the Tests: 318 passed in |
amy-why-3459
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround. I re-checked only what changed between adfa5dd and c7d5be3 (the two commits were rewritten, so I compared them against the previous versions rather than reviewing them from scratch).
Addressed, as far as I can see:
- Padding before
commit():_commit_clip_then_padnow awaitsclient.commit()before creating the pad task, and the oldif not server_turns:pre-commit start is gone.test_padding_starts_only_after_the_commit/test_sample_pads_only_after_its_commituse a commit that yields before it lands, which is the right way to catch the race. - Padding tests: the new
test_omni_duplex_eval_drain_padding.pycovers ordering, stopping when the drain finishes, an already-set stop event and a closed socket. - Judge retry tracing: both a
JudgeResponseErrorand a secondparse_judge_jsonfailure on the follow-up now write ajudge_retry: Truetrace line before re-raising. - Timeout retry: the always-retry trade-off and the ~2x worst case are now documented next to the
asyncio.TimeoutErrorbranch. _duplex_reported_error: matching whole"; "-separated entries instead ofstr.replacefixes the substring case (it lines up witherror="; ".join(errors)ingenerate_sample).
A few remaining code-level points:
-
Does padding really leave the activity window alone? The
_pad_silence_until_stoppeddocstring says the padded units "do not reset the completion activity window", but_COMPLETION_ACTIVITYincludesresponse.listen. If the duplex step loop emits aresponse.listen(or an empty audio/text delta) for each step driven by a padded silence unit,_completion_activity(events, commit_from)keeps growing,stable_sincekeeps resetting, andresponses_finishedcan only end via_drain_timeout_s(duration + 120 s). The new tests use a fake client that emits no events for appended audio, so they can't catch this. It would be good to confirm what a real server sends for a non-speech padded unit, and if it emitsresponse.listen, either stop padding once all open responses are done or exclude events caused by padding from the count. The other half of my earlier point is still open too: if the model starts speaking during the padded silence, that new response is scored as part of the sample. -
ack_timeoutis recorded but never reachesresult.error.drain_timeoutandclose_timeoutare folded intoerrors, but a final playback-ack timeout only lands inmetadata["ack_timeout"]. So a sample whose closing ack stalled can still count assuccessand is never considered by_duplex_retryable_stall/_duplex_timeout_reason, which only look at the other two keys. If this is intentional (the ack is advisory), a short comment would help; otherwise it should be handled the same way asclose_timeout. -
_force_close_transportdepends on the privateclient._ws. IfRealtimeDuplexClientrenames or wraps that attribute, the function quietly does nothing and the exit path waits on the handshake again, which is exactly the hang this PR fixes. A small publicabort()on the client, or at least a test that fails when_ws/transportis missing, would keep this from regressing silently. -
Minor: the ack/close paths catch the builtin
TimeoutErroraroundasyncio.wait_for, while_pad_silence_until_stoppedandpatch.pycatchasyncio.TimeoutError. They are the same class on Python 3.11+, but on 3.10wait_forraises onlyasyncio.TimeoutError, so the ack timeout would escape. If 3.10 is still supported, usingasyncio.TimeoutErrorconsistently avoids that.
yenuo26
left a comment
There was a problem hiding this comment.
Tip-move re-review (adfa5dd → c7d5be3)
Re-checked the tip against my prior findings on adfa5dd and @amy-why-3459's tip delta notes.
Prior findings addressed on this tip
- Outer
CancelledErrorswallow — theexcept asyncio.CancelledError: passaround_bounded_ack_joinis gone; outer cancel propagates. Author addedtest_cancelling_the_sample_during_the_ack_join_propagates. - Padding before
commit()—_commit_clip_then_padawaitsclient.commit()before scheduling the pad task. - Padding coverage —
tests/benchmarks/duplex/test_omni_duplex_eval_drain_padding.pycovers ordering (including a yielding commit), stop, already-stopped, and closed-socket quiet exit;test_sample_pads_only_after_its_commitdrivesgenerate_sample. - Judge retry tracing — follow-up
JudgeResponseError/ secondparse_judge_jsonmiss both_tracewithjudge_retry: Truebefore re-raising.
Also confirmed: stall-only retry gate still strips whole "; "-separated timeout entries; outer per-request timeout always-retry is documented; ack_timeout sentinel str(exc) or "playback ack timed out" is in place; UT placement under tests/benchmarks/{duplex,patch}/ stays component-local. requires-python >=3.11 makes the builtin/asyncio.TimeoutError split moot here.
Open soft residuals (already tracked by @amy-why-3459 on this tip — non-blocking for my bar)
- Confirm (or comment) that non-speech pad steps do not emit
_COMPLETION_ACTIVITYevents such asresponse.listenon a real server; fake-client pad tests cannot catch that. Related: a reply opened during padded silence is still scored with the sample. ack_timeoutlands in metadata only (not folded intoresult.error/_duplex_timeout_reason); if advisory, a one-line comment next to the fold would help._force_close_transportquietly no-ops if privateclient._wsmoves — publicabort()or a missing-transport regression would harden it (author already flagged this as out of scope).
Verdict: prior hard findings cleared on c7d5be3. Soft residuals above can land as follow-ups; I am not blocking on them.
c7d5be3 to
99b1251
Compare
|
Thanks — all four are fair; three are doc/guard level and one needed a measurement. 1. Do padded units leave the activity window alone? Measured, not assumed. For that reason I did not add "stop padding once every open response is done": the padded steps are exactly what lets the residual unit get decided, so stopping at "all responses done" could leave 2. 3. 4. Head |
amy-why-3459
left a comment
There was a problem hiding this comment.
Thanks for the measurement and the follow-ups. I only looked at what changed between c7d5be3 and 99b1251 (same 2 commits, small delta).
Addressed:
ack_timeout: the comment now states it is advisory (recorded for diagnosis, not folded intoerror, not a retry reason). That settles my question._wsdependency:_force_close_transportnow logs a warning when the client has no_ws, andtest_teardown_reports_a_client_without_a_transportpins it.TimeoutError: unified on the builtin in_pad_silence_until_stopped; withrequires-python >= 3.11that is correct.- Activity window: thanks for the 0/66
drain_timeoutdata point; that answers the question for the current server behavior.
Two small code-level notes on the new delta:
-
The missing-transport case is still silent. The new warning only fires when
client._wsis missing. If_wsexists butgetattr(ws, "transport", None)returnsNone(e.g. a websockets upgrade that moves or renames the attribute), the abort is skipped without a log and the code goes straight to the gracefulws.close(). It is bounded by_FORCE_CLOSE_GRACE_S, so it won't hang, but it is the same "quiet no-op" this change is meant to surface. Logging in thetransport is Nonebranch too (and a matching one-line test) would cover both renames. -
The
_pad_silence_until_stoppeddocstring still states the activity-window property as a fact of the code ("The units are non-speech and are not committed, so they do not reset the completion activity window"). Per your measurement it is really an observed server behavior (noresponse.listen/ delta events for non-speech units appended after a commit). Phrasing it that way, and pointing at the validation run, would make it clear what to recheck if drains start ending at_drain_timeout_safter a server-side change.
…aining reply moving A duplex sample writes its artifacts only after the session tears down, and that teardown had three socket writes with no deadline of their own: the closing acknowledge_playback(), the session.close() inside close_session(), and the cancelled ack loop's join. When the socket stopped draining, the sample hung until the benchmark's per-request timeout (900 s) and was cancelled with defer_artifacts=True, so it wrote no artifacts at all, the accuracy pass counted it as skipped, and the guard failed with assert completed == 66 while nothing in the log explained why. Bound all three writes; a close that ignores its deadline aborts the transport and closes it without a handshake. Retry only a stall that came alone: a failure the session itself reported - a server_errors entry, or anything left in result.error once the recorded timeout strings are stripped - gets exactly one retry, so a deterministic failure cannot be retried into a pass. Retries are visible per attempt (latency, retry_count, run-level tallies). The drain itself had a second, deterministic stall: the server's step loop is driven by the input frames the client appends, and the client stops appending the moment the clip ends, so a reply still open at EOF froze mid-utterance - audio and transcript deltas stopped together and response.done never arrived (1-3 of 66 samples per run, 6 of 6 attempts on two of them). Pad the drain with silence in the session's own chunk period and size the drain window from the input duration plus 120 s, so a reply that outlasts the clip still finishes. The timeout path now logs the completion trace, because a stalled sample keeps no artifacts to inspect. Verified on the 66-sample duplex run (Ascend 910B, temperature 0): completed 66/66, 0 stalled sessions and 0 drain timeouts, where the previous harness lost 1-3 samples per run. Signed-off-by: y-null <y-null@users.noreply.github.com>
…core fields
Sample 512 (RTD_interaction_relation) answers the judge's re-read request
with conversational text ("C cuts the pineapple") instead of the required
score JSON. The judge trace records that raw reply and no score file is
written, so the run reports evaluated 65 < 66 and status="partial", and the
accuracy assertion fails - deterministically, on every run.
Echo the offending reply back once with the format instruction instead of
failing the sample: the pinned scoring prompt is not reworded, and every
retry is recorded in the trace (judge_retry: true).
Signed-off-by: y-null <y-null@users.noreply.github.com>
99b1251 to
d492301
Compare
|
Both taken, in 1. The 2. The docstring no longer states it as a property of the code. Head |
amy-why-3459
left a comment
There was a problem hiding this comment.
Thanks, I only looked at what changed between 99b1251 and d492301 (same 2 commits, small delta).
Both notes from my last pass are addressed:
- Missing-transport case:
_force_close_transportnow logsrealtime transport has no abortable handle on <socket class>whenws.transportisNoneand still falls through to the boundedws.close().test_teardown_reports_a_client_without_a_transportnow exercises both shapes (a client without_ws, and a socket withouttransport) and asserts on each warning. _pad_silence_until_stoppeddocstring: it now describes the activity-window behavior as observed server behavior, cites the 0/66drain_timeoutvalidation run, and says what to recheck first if drains start ending at_drain_timeout_s. That reads correctly.
One tiny optional nit: the test now replaces runner.logger.warning with lambda msg, *args: ..., which would raise TypeError if a future call passes keyword arguments such as exc_info=True. Accepting **kwargs in the lambda (or going back to caplog with the logger name) would keep the test from breaking on that kind of change. Not blocking.
No further code-level concerns on this delta from my side.
yenuo26
left a comment
There was a problem hiding this comment.
Tip-move re-review (c7d5be3 → d492301)
Re-checked the tip delta against my prior soft residuals on c7d5be3 and @amy-why-3459's notes on 99b1251 / d492301.
No blocking P0/P1
Prior soft residuals addressed on this tip
ack_timeoutadvisory — comment now states it is recorded for diagnosis only (not folded intoerror, not a retry reason)._force_close_transportquiet no-op — logs whenclient._wsis missing and whenws.transportisNone(realtime transport has no abortable handle on …), then still falls through to the boundedws.close().test_teardown_reports_a_client_without_a_transportcovers both shapes and asserts each warning.- Pad activity-window docstring —
_pad_silence_until_stoppednow describes non-speech post-commit pad steps as observed server behavior (noresponse.listen/delta), cites the 0/66drain_timeoutvalidation run, and says what to recheck first if drains start ending at_drain_timeout_s.
Hard findings from earlier rounds remain cleared (outer CancelledError propagates; pad starts after commit(); pad/judge-retry/close-stall suites in place; stall-only retry gate; whole-entry timeout stripping).
Non-blocking
- Peer soft nit from @amy-why-3459 on this tip:
test_teardown_reports_a_client_without_a_transportreplacesrunner.logger.warningwithlambda msg, *args: …, which wouldTypeErrorif a future call passes kwargs such asexc_info=True. Accepting**kwargs(or usingcaplog) would harden the stub. Not blocking.
CLEAN tip-move from this account’s side. Not approving — please self-approve / wait for a human approve when ready.
|
Closing the one finding from the 2026-10-23 closure re-review that never got a reply: the It is gone in The regression you asked for is in The DCO note from the same round is covered too — every commit on the branch is signed off. |
amy-why-3459
left a comment
There was a problem hiding this comment.
Reviewed at 9a806e697143ecd8d0a19deade0713b92c63b3d7.
The direction (bounding the teardown writes, retrying only a lone stall, making retries visible) makes sense, but I don't think this is merge-ready yet. One of the headline fixes is a no-op against the real client, and one documented guarantee does not hold on the benchmark path. Details are inline; summary:
-
Blocking: drain padding never sends anything.
_pad_silence_until_stoppedcallsclient.append_audio(...), but the runner'sRealtimeDuplexClient(vllm_omni/experimental/fullduplex/client.py) has noappend_audio; onlyvllm_omni/clients/duplex.pydoes. TheAttributeErroris swallowed byexcept Exception: return, so the padding task exits immediately without a log. Reproduced locally with the real class (hasattr(client, "append_audio") == False, task done at once, zero events sent). The tests pass only because_RecordingClientimplementsappend_audio. This also means the reported 66/66 / 0-stall result cannot be attributed to padding; it can only come from the larger drain window (input + 120 s) plus retries. That conflicts with the stated diagnosis (if the step loop only advances on client appends, a longer window alone should not let a frozen reply finish). Please clarify which code the E2E run used and re-validate after the fix. -
close_timeoutstill drops the sample on the benchmark path. It is still folded intoresult.error,patch.pysetssuccess = response_done and not result.error, andfinalize_duplex_evalonly writes failed-session diagnostics for unsuccessful outputs. So withdefer_artifacts=Truea sample whose response completed but whose close stalled is retried as a whole, and if the close stalls again it is still not published. The "still writes its artifacts" claim (andtest_sample_still_produces_a_result_when_the_close_send_stalls) only holds fordefer_artifacts=False. Either treatclose_timeoutas advisory whenresponse_doneis true (likeack_timeout), or update the description and add adefer_artifacts=Truetest. -
Minor: the outer deadline in
_close_duplex_sessionequals the innerclose_session(timeout_s=...), so a slow-but-successful close is always force-aborted. The broadasyncio.TimeoutErrorretry also catches non-request timeouts (e.g.configure()waiting forsession.created) under a "per-request timeout" log. The judge follow-up fires on anyValueError(including invalid field values) while its wording says the fields are missing.
Architecture: sound under stated assumptions. Two points need an explicit maintainer decision: (a) a client send that never drains means the server stopped reading the socket. Bounding and retrying in the harness keeps CI green but may hide a serving defect, and the CI guard does not check duplex_retry_count. (b) Padding input after a final: True commit changes the evaluation protocol; please compare it with how the official Omni-DuplexEval handles end-of-clip.
Validation: tests/benchmarks/{duplex,patch} pass locally on this head (326 passed), but they do not cover the real client. Please run the complete repository unit-test suite on 9a806e6971 (or the updated head), including the required CI shards and backend-specific UT jobs. Attach the commands, environment, result counts, and CI/log links, and explain every skipped or deselected test and any unavailable job. After fixing (1), please also re-run the 66-sample E2E case on the new head.
| silence = bytes(round(PCM16_SAMPLE_RATE * period_ms / 1000) * PCM16_BYTES_PER_SAMPLE) | ||
| while not stop.is_set(): | ||
| try: | ||
| await client.append_audio(silence, is_speech=False) |
There was a problem hiding this comment.
RealtimeDuplexClient from vllm_omni.experimental.fullduplex.client (the class this runner instantiates) has no append_audio method; it only exists on the vllm_omni/clients/duplex.py client. This raises AttributeError, the except Exception: return below swallows it, and the padding task ends before sending a single unit. Verified with the real class: zero events sent.
Suggest building the input_audio_buffer.append event and sending it through client.send, consistent with stream_av_units (duration_ms, audio_end_ms, advancing _media_clock_ms). Only treat ConnectionClosed/OSError as end-of-padding, and add a regression test against the real RealtimeDuplexClient with only send stubbed.
There was a problem hiding this comment.
Fixed in #9802. RealtimeDuplexClient.append_silence sends the same input_audio_buffer.append event shape stream_av_units sends (duration_ms/audio_end_ms included, _media_clock_ms advanced), and the padding loop now catches only (ConnectionClosed, OSError). test_the_fake_client_mirrors_the_real_client_surface guards the fake client against drifting from the real class again, and test_padding_works_against_the_real_client runs the real client with only send stubbed. One consequence worth stating: the 0/66 drain_timeout I quoted from the 2026-11-01 run predates this fix — that run's padding was exactly this no-op, so it does not speak to how the real server reacts to appended silence. A real-client run is follow-up.
| errors.append(str(event)) | ||
| server_errors.append(str(event)) | ||
| errors.extend(server_errors) | ||
| errors.extend(str(error) for error in (drain_timeout, close_timeout) if error) |
There was a problem hiding this comment.
close_timeout is still part of result.error, so on the benchmark path (defer_artifacts=True) output.success is false and finalize_duplex_eval publishes only failed-session diagnostics, not the sample. A response that completed but whose close stalled is therefore retried in full, and is lost if the close stalls again. If the intent is that a stalled close costs only the handshake, consider keeping it advisory when response_done is true (as with ack_timeout), and cover it with a defer_artifacts=True test through _async_request_duplex_eval + finalize_duplex_eval.
There was a problem hiding this comment.
Fixed in #9802. close_timeout stays out of result.error once the reply is done (metadata only), so output.error is empty and the sample is published; _duplex_retryable_stall returns nothing for response_done=True. Covered by test_completed_sample_with_a_stalled_close_is_not_retried and test_completed_sample_with_a_stalled_close_is_published.
| task = asyncio.ensure_future(client.close_session(timeout_s=timeout_s)) | ||
| task.add_done_callback(_consume_task_exception) | ||
| try: | ||
| await asyncio.wait_for(asyncio.shield(task), timeout=timeout_s) |
There was a problem hiding this comment.
Nit: this outer deadline equals the inner close_session(timeout_s=timeout_s), and the inner one only starts after the send returns. So the outer timer always fires first, and a close that would still have received session.closed is force-aborted. Consider timeout_s + _FORCE_CLOSE_GRACE_S (or a smaller inner timeout) for the outer bound.
There was a problem hiding this comment.
Fixed in #9802 — the outer wait is now timeout_s + _FORCE_CLOSE_GRACE_S.
| ), | ||
| timeout=_omni_request_timeout_s(), | ||
| ) | ||
| except asyncio.TimeoutError: |
There was a problem hiding this comment.
This also catches TimeoutErrors raised inside generate_sample that are not the per-request timeout (e.g. configure() timing out on session.created). Those get retried and logged as "hit the per-request timeout". Consider distinguishing the outer wait_for timeout (e.g. an explicit deadline check or a wrapper exception) so the log and retry reason stay accurate.
There was a problem hiding this comment.
Fixed in #9802. The retry branch now compares elapsed time against the per-request deadline: a TimeoutError raised well before the deadline (e.g. the configure handshake) is logged as an inner wait_for leak, and the deadline keeps its own message. Covered by test_timeout_logging_distinguishes_inner_and_outer_timeouts.
| _trace({"raw_response": raw_response}) | ||
| try: | ||
| return parse_judge_json(raw_response, required=required) | ||
| except ValueError: |
There was a problem hiding this comment.
Minor: any ValueError from parse_judge_json triggers the follow-up, including Invalid judge field ... (field present, value malformed), but _JUDGE_FORMAT_FOLLOWUP says the fields are missing. Consider passing the parse error into the follow-up, and recording it on the first trace line, so the trace shows which reply failed to parse.
There was a problem hiding this comment.
Fixed in #9802. The parse error itself rides the follow-up — Invalid judge field ... no longer gets the fields-are-missing phrasing — and the first trace line records it. Covered by test_parse_error_reaches_the_retry_and_the_trace.
yenuo26
left a comment
There was a problem hiding this comment.
Tip-move re-review (d492301 → 9a806e)
Tip is a main merge plus the prior stall/retry/padding work. Re-checked the runner/patch/judge paths against my last CLEAN on d492301 and @amy-why-3459's new tip threads.
No Blocking (P0)
Blocking (P1)
- Drain padding is a no-op against the real client (agree with @amy-why-3459) —
_pad_silence_until_stoppedcallsclient.append_audio(...), but the runner instantiatesRealtimeDuplexClientfromvllm_omni.experimental.fullduplex.client, which has noappend_audio(onlysend/stream_av_units/stream_pcm16). The bareexcept Exception: returnswallows theAttributeError, so padding ends before any unit is sent. That undercuts one of the headline fixes (frozen reply at EOF). Please drive padding throughclient.sendwith aninput_audio_buffer.appendshaped likestream_av_units(duration_ms,audio_end_ms,_media_clock_ms), treat onlyConnectionClosed/OSErroras end-of-padding, and add a regression that uses the real client class withsendstubbed (not a duck-typed fake that happens to exposeappend_audio).
Non-blocking (open peer soft nits on this tip — please address or reply):
2. close_timeout still folded into result.error — on defer_artifacts=True, a completed reply whose close stalls is published as a failed session and can be retried/lost. Consider making close_timeout advisory when response.done already landed (same spirit as ack_timeout), with a defer_artifacts=True path through _async_request_duplex_eval + finalize_duplex_eval.
3. Outer close deadline equals the inner close_session timeout — outer wait_for always wins, so a close that would still see session.closed gets force-aborted. Prefer timeout_s + _FORCE_CLOSE_GRACE_S (or a smaller inner timeout).
4. except asyncio.TimeoutError around wait_for(generate_sample(...)) also catches timeouts raised inside generate_sample (e.g. configure() / session.created) and logs them as per-request timeouts. Distinguish the outer deadline.
5. Judge follow-up on any ValueError from parse_judge_json — including malformed-but-present fields — while _JUDGE_FORMAT_FOLLOWUP talks about missing fields. Pass the parse error into the follow-up / first trace line.
Happy to re-review once (1) is fixed; (2)–(5) can land in the same tip or a fast follow-up.
… and a scoreless judge reply (vllm-project#9260) Signed-off-by: y-null <y-null@users.noreply.github.com> Co-authored-by: y-null <y-null@users.noreply.github.com> Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
Split out of #9207 on review feedback: this changes how every Omni-DuplexEval run scores failures, so it gets its own review instead of riding along with the Code2Wav fix.
Extended on 2026-11-01 with the other two ways a 66-sample run loses samples: a reply that freezes when the input ends, and a judge reply that carries no score at all. The branch is rebased onto current
main, which the judge retry depends on (#9390 rejects an incomplete judge reply; this PR re-asks for one instead of failing the sample).Problem
A duplex sample writes its artifacts only after the session tears down. Two writes on that path have no deadline of their own — the closing
acknowledge_playback()and thesend({"type": "session.close"})insideclose_session()— and both ride the socket the session already used. When that socket stops draining, the sample hung until the benchmark's per-request timeout (900 s), was cancelled withdefer_artifacts=True, and therefore wrote no artifacts at all. The accuracy pass counted it asskipped, and the C8 guard failed withassert completed == num_promptwhile nothing in the log explained why.Measured on a C8 replay of the MiniCPM-o 4.5 duplex case (Ascend 910B), with the graph-capture stall of #9207 already fixed and 0 graph captures during the replay: 7 of 66 samples lost, then 1 of 66 once the ack was bounded — the remaining one was the close.
Changes
_FINAL_ACK_TIMEOUT_S, 20 s) and keep it advisory — it only checkpoints how much audio was played, so the sample still writes its artifacts and recordsack_timeout._FINAL_CLOSE_TIMEOUT_S): run it as a task behind one deadline, and on timeout abort the transport and close it without a handshake (_force_close_transport).close_session()awaits an unbounded send before its own timeout starts, and the client's exit path awaits the same handshake, so a stuck write would otherwise still hang teardown. The failure is recorded asclose_timeout.server_errorsentry, or anything left inresult.erroronce the recorded timeout strings are stripped — gets exactly one attempt, so a deterministic failure cannot be retried into a pass.retry_countnext to the deferred artifacts, andduplex_retried_samples/duplex_retry_countin the run summary.output.error(no completed response) so an unsuccessful sample is diagnosable instead of silently missing.Problem: a reply that freezes at end of input
The server's step loop is driven by the frames the client appends, and this client stops appending the moment the clip ends. A reply still open at end of input therefore froze mid-utterance: the audio and transcript deltas stopped together and
response.donenever arrived. This hit 1-3 of 66 samples per run and was reproducible in 6 of 6 attempts on two of them, so it was not jitter.Evidence that the reply was still being generated rather than lost: every stalled request ended with
OmniTiming total=<drain window>, i.e. the engine only stopped when the client gave up.Problem: a judge reply with no score fields
Sample 512 (
RTD_interaction_relation) answers the judge's re-read request with conversational text (C cuts the pineapple). Its512.judge.jsonlrecords that raw reply and no512.jsonis written, so the run reportsevaluated 65 < 66,status="partial", and the accuracy assertion fails — deterministically, on every run.Changes (continued)
_pad_silence_until_stopped), so the step loop keeps running while the tail reply finishes.input duration + 120 s(_DRAIN_SLACK_S), because a 37 s soundtrack asked for a summary answers for close to a minute while the window used to stop at input + 20 s.Tests
tests/benchmarks/patch/test_duplex_eval_retry.py,tests/benchmarks/duplex/test_omni_duplex_eval_close_stall.py,tests/benchmarks/duplex/test_omni_duplex_eval_drain_padding.py,tests/benchmarks/duplex/test_omni_duplex_eval_judge_retry.py(+ judge/serving suites) — 318 passed intests/benchmarks/{duplex,patch}/(1 pre-existing failure:torchcodecmissing in the local container):result.erroris retried once; a server error plus a drain timeout is a single attempt with the error preservedVerified end to end on the 66-sample duplex run (Ascend 910B, temperature 0):
completed 66/66,published 66,artifact_errors [],accuracy evaluated 66 / failed 0,status completed, and the accuracy floors the config enforces (rtd.mean_content_score >= 1.8,rtd.mean_avg_temporal_score >= 1.4,pr.mean_all_success >= 0.28) all pass — with 0 stalled sessions and 0 drain timeouts, against 1-3 stalled sessions per run before. Two samples that stalled in 6 of 6 attempts now complete.Reported latency grows accordingly (
mean_e2el55 s,duplex_stream_ttftp99 64 s): the long tails are no longer dropped from the summary.