fix(avatar): recover DataStreamAudioReceiver from a lost stream trailer - #7007
Conversation
42fac90 to
d87d9ae
Compare
d87d9ae to
012e9ba
Compare
longcw
left a comment
There was a problem hiding this comment.
looks good to me, something nit:
| return "reject" | ||
|
|
||
| if self._current_reader: | ||
| self._current_reader_cleared = True |
There was a problem hiding this comment.
should we also set the _current_reader_superseded event here?
There was a problem hiding this comment.
Good call β done in f679b2f. clear_buffer now sets the event too, so a cleared segment ends immediately instead of waiting on a chunk, the next header, or the idle timeout when its trailer is lost. One nuance: after a normal interruption the trailer usually still arrives, so the early exit logs at debug (not warning) when the reader was cleared. Added tests for both the lost-trailer and healthy-trailer clear cases.
| logger.debug( | ||
| "audio stream ended early after clear_buffer", | ||
| extra={"stream_id": reader.info.stream_id}, | ||
| ) |
There was a problem hiding this comment.
FYI: After looking into this, this is what I found:
This id is an SDK-generated UUID (the sender calls stream_bytes() without a custom stream_id), and the receiver only accepts streams from the identity-verified avatar sender β it's transport metadata, not user content. lk.pii.* is used upstream for transcripts/tool arguments, and stream_id is already logged unredacted elsewhere (e.g. soniox plugin, and the warning a few lines below).
Problem
DataStreamAudioReceiverends an audio segment only when that segment's byte-stream trailer arrives. The receive loop is a bareasync for data in self._current_reader, andByteStreamReader.__anext__blocks until_on_stream_close(the trailer) enqueues the terminating sentinel.Reliable data delivery is best-effort β a transport reset or a full reconnect on either side can drop an in-flight stream β so a trailer can be lost. When it is, the loop blocks on that reader forever, and because the outer
while self._stream_readersloop never pops the next reader, every later segment queues unread:AudioFrames, noAudioSegmentEndβ the sender'swait_for_playout()never resolves andlk.playback_finishedis never sent again;lk.clear_bufferRPCs still return"ok"(the handler only sets_current_reader_cleared, which the blocked loop never re-checks), so the receiver looks alive;This is the mechanism behind the recurring "avatar stops receiving audio after several consecutive interruptions" reports (#3434, #3237): repeated interruptions raise the odds of a mid-stream reset that drops a trailer. It's provider-agnostic β we hit it in production behind an Anam avatar, but any DataStream avatar worker that survives a data-channel blip is exposed.
Fix
A sender never overlaps streams β it closes segment N before opening N+1 β so a new stream header arriving while the current reader is still open proves that reader's trailer will never come.
_recv_tasknow iterates through a small helper,_iter_reader, that races each__anext__against:_handle_stream_receivedwhen a new reader arrives with one already open, andSTREAM_IDLE_TIMEOUT = 10s) for the tail case, where the last segment before an idle gap has no following stream to supersede it.A queued chunk or trailer always wins the race, so healthy and legitimately-slow segments are byte-for-byte unchanged; the escape hatches only fire when a trailer is genuinely missing. Worst case degrades from a permanent wedge to a single truncated segment.
Only
_datastream_io.pychanges; no wire-format, plugin, or sender change.Tests
tests/test_datastream_receiver_lost_trailer.py(self-contained, fake in-memory readers, no network):clear_bufferis recovered;Against
mainthe lost-trailer cases hang; with this change all pass.ruff formatandruff checkare clean.Closes #3434, #3237.