Skip to content

fix: guard audio decode against locale-sensitive PyAV failures#6288

Open
nightcityblade wants to merge 1 commit into
livekit:mainfrom
nightcityblade:fix/issue-2886
Open

fix: guard audio decode against locale-sensitive PyAV failures#6288
nightcityblade wants to merge 1 commit into
livekit:mainfrom
nightcityblade:fix/issue-2886

Conversation

@nightcityblade

Copy link
Copy Markdown
Contributor

Fixes #2886

Summary

  • wrap the PyAV/FFmpeg decode path in a temporary C locale guard
  • restore the caller locale after decode, even on exceptions
  • add targeted regression tests for locale switching/restoration

Validation

  • uv run pytest tests/test_audio_decoder.py -q -k "temporary_av_locale or stream_buffer"
  • uv run ruff check livekit-agents/livekit/agents/utils/codecs/decoder.py tests/test_audio_decoder.py

@nightcityblade nightcityblade requested a review from a team as a code owner July 2, 2026 03:18

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

Open in Devin Review

Comment on lines +438 to +486
with _temporary_av_locale():
# open container in low-latency streaming mode
container = av.open(
self._input_buf,
mode="r",
format=self._av_format,
buffer_size=256,
options={
"probesize": "32",
"analyzeduration": "0",
"fflags": "nobuffer+flush_packets",
"flags": "low_delay",
"reorder_queue_size": "0",
"max_delay": "0",
"avioflags": "direct",
},
)
# explicitly disable internal buffering flags on the FFmpeg container
container.flags |= cast(
int, av.container.Flags.no_buffer.value | av.container.Flags.flush_packets.value
)

if len(container.streams.audio) == 0:
raise ValueError("no audio stream found")
if len(container.streams.audio) == 0:
raise ValueError("no audio stream found")

audio_stream = container.streams.audio[0]
audio_stream = container.streams.audio[0]

# Set up resampler only if needed
if self._sample_rate is not None or self._layout is not None:
resampler = av.AudioResampler(
format="s16", layout=self._layout, rate=self._sample_rate
)
# Set up resampler only if needed
if self._sample_rate is not None or self._layout is not None:
resampler = av.AudioResampler(
format="s16", layout=self._layout, rate=self._sample_rate
)

for frame in container.decode(audio_stream):
if self._closed:
return
for frame in container.decode(audio_stream):
if self._closed:
return

if resampler:
frames = resampler.resample(frame)
else:
frames = [frame]
if resampler:
frames = resampler.resample(frame)
else:
frames = [frame]

for f in frames:
self._emit_av_frame(f)
for f in frames:
self._emit_av_frame(f)

# flush the resampler to get any remaining buffered samples
if resampler and not self._closed:
for f in resampler.resample(None):
self._emit_av_frame(f)
# flush the resampler to get any remaining buffered samples
if resampler and not self._closed:
for f in resampler.resample(None):
self._emit_av_frame(f)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Global lock held for entire audio stream lifetime serializes all concurrent decoders

A process-wide lock is held for the entire duration of audio decoding (_temporary_av_locale() at livekit-agents/livekit/agents/utils/codecs/decoder.py:438) including blocking I/O waits, so concurrent audio streams are fully serialized.

Impact: When multiple agents or TTS sessions decode audio simultaneously, each waits for the previous one to finish entirely, causing severe latency.

The _AV_LOCALE_LOCK is held across the blocking decode loop

The _temporary_av_locale() context manager acquires _AV_LOCALE_LOCK (a global threading.Lock at decoder.py:37) and yields while holding it (decoder.py:80-84). The _decode_loop wraps the entire decode loop inside this context manager (decoder.py:438-486), including:

  1. av.open(self._input_buf, ...) — which calls StreamBuffer.read() to probe the format
  2. container.decode(audio_stream) — a generator that repeatedly calls StreamBuffer.read(), which blocks at decoder.py:145 (self._data_available.wait()) until more data arrives

Since StreamBuffer is fed incrementally by push() calls, the lock is held for the entire lifetime of the audio stream (potentially minutes). A second AudioStreamDecoder in another thread (e.g., a separate TTS pipeline via livekit-agents/livekit/agents/tts/tts.py:1106) will block on acquiring this lock until the first stream completes entirely.

The lock's purpose is to protect a brief locale change, but its scope covers long-running blocking I/O. A narrower scope — e.g., wrapping only the av.open() call or individual container.decode() iterations — would avoid serializing unrelated streams.

Prompt for agents
The _temporary_av_locale() context manager in _decode_loop (decoder.py:438) holds the global _AV_LOCALE_LOCK for the entire duration of audio decoding, including blocking StreamBuffer.read() calls. This serializes all concurrent AudioStreamDecoder instances in the process.

The lock is needed to protect the process-wide locale change (setlocale is not thread-safe and affects the entire process). However, it should be held for the minimum time necessary.

Possible approaches:
1. Narrow the scope: only wrap av.open() (the format-probing step) in the locale context manager, since that's the most likely locale-sensitive operation. The decode loop itself (container.decode) would run outside the lock.
2. If container.decode is also locale-sensitive, consider acquiring and releasing the lock around each individual frame decode iteration rather than the entire loop.
3. Consider using a different mechanism altogether, such as setting the locale once at process startup rather than toggling it per-decode.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +70 to +86
@contextmanager
def _temporary_av_locale() -> Iterator[None]:
"""Force a stable C locale while PyAV/FFmpeg decodes audio.

PyAV can surface locale-sensitive decode paths through FFmpeg. Some
non-ASCII locales trigger UnicodeDecodeError while pulling audio frames,
so we temporarily switch to the C locale around decode/resample work and
then restore the caller's locale.
"""

with _AV_LOCALE_LOCK:
previous = locale.setlocale(locale.LC_ALL)
try:
locale.setlocale(locale.LC_ALL, "C")
yield
finally:
locale.setlocale(locale.LC_ALL, previous)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 locale.setlocale is not thread-safe per POSIX / CPython docs

Beyond the lock-scope issue reported as a bug, there is a deeper concern: locale.setlocale() affects the entire process and the CPython docs explicitly warn it is not thread-safe. Even with the _AV_LOCALE_LOCK serializing callers of _temporary_av_locale, other threads in the process (including the event loop thread, other plugins, or user code) may observe the temporarily changed locale without any synchronization. This is inherent to the approach and worth acknowledging — the lock only protects against concurrent _temporary_av_locale callers, not against the broader process seeing 'C' locale mid-operation.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


nightcityblade seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

then restore the caller's locale.
"""

with _AV_LOCALE_LOCK:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the thread lock serializes every audio decode in this process.

Monkiia commented Jul 3, 2026

Copy link
Copy Markdown

I dug into this and agree the current lock scope is too broad. The tricky part is that the locale workaround is process-wide, so simply narrowing the lock can reintroduce the original decode failure. I haven't pushed a follow-up yet because I want to avoid trading one correctness bug for another; I'll come back with a safer revision rather than guessing here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BackgroundAudioPlayer.play() fails on pl_PL.utf8 encoding

4 participants