Skip to content

fix(encoder): release MediaCodec output buffers immediately instead of on Frame.close() - #304

Closed
filippoadessi wants to merge 1 commit into
ThibaultBee:mainfrom
filippoadessi:fix/mediacodec-output-buffer-backpressure
Closed

fix(encoder): release MediaCodec output buffers immediately instead of on Frame.close()#304
filippoadessi wants to merge 1 commit into
ThibaultBee:mainfrom
filippoadessi:fix/mediacodec-output-buffer-backpressure

Conversation

@filippoadessi

Copy link
Copy Markdown

Fixes #302

Title: Encoder stalls under downstream backpressure (network congestion / slow endpoint) because MediaCodec output buffers aren't released until the whole pipeline finishes with the Frame

Environment

  • StreamPack 3.2.0 (core), MediaCodecEncoder
  • Any endpoint where sending can block or slow down (observed with SRT under
    constrained uplink bandwidth, but the mechanism is endpoint-agnostic)
  • HEVC and H.264 both affected (codec-independent — it's about buffer
    lifetime, not bitstream content)

Symptom

Under real network congestion (limited uplink bandwidth, or a downstream
consumer that reads slower than the encoder produces), the encoder
periodically stops producing output entirely for multiple seconds at a time,
then resumes — visible downstream as multi-second freezes/gaps in the
published stream, confirmed via byte-level capture of the muxed output
(literal gaps of missing bytes, not just paced/throttled ones). This is not a
graceful bitrate/quality adaptation — it's a hard stall of encoder output.

Root cause

MediaCodecEncoder.FrameFactory.createFrame() wraps the ByteBuffer handed
out by MediaCodec.getOutputBuffer(index) by reference (no copy) into the
Frame object, and only calls codec.releaseOutputBuffer(index, false)
inside the Frame's onClosed callback:

val rawBuffer = if (extraBuffers != null) {
    buffer.removePrefixes(extraBuffers)
} else {
    buffer
}

return pool.get(
    rawBuffer,
    ...
    onClosed = {
        try {
            codec.releaseOutputBuffer(index, false)
        } catch (t: Throwable) {
            Logger.w(tag, "Failed to release output buffer for code: ${t.message}")
        }
    })

Frame.close() (and therefore onClosed) only runs after the frame has been
fully consumed by everything downstream: the processing pipeline, the muxer,
and ultimately the network send call of the endpoint. MediaCodec has a
small, fixed pool of output buffers (commonly ~4 on Android hardware
encoders). If the network send blocks or falls behind — exactly the
congestion scenario a live encoder needs to survive — frames stop being
close()d, the output buffer pool is exhausted, and MediaCodec itself stops
being able to produce new encoded output until buffers free up. The encoder
is effectively backpressured all the way from the socket write into the
hardware codec, with nothing in between to absorb the mismatch.

RootEncoder (pedroSG94/RootEncoder) doesn't hit this: it copies the encoded
bytes out of the MediaCodec buffer immediately and releases the buffer right
away, decoupling encoder throughput from however fast the network consumer
is.

Fix

Copy the encoded bytes out of the MediaCodec output buffer and call
codec.releaseOutputBuffer(index, false) immediately in createFrame(),
instead of holding the codec buffer by reference until Frame.close():

val rawBuffer = ByteBuffer.allocate(sourceBuffer.remaining())
rawBuffer.put(sourceBuffer)
rawBuffer.rewind()
try {
    codec.releaseOutputBuffer(index, false)
} catch (t: Throwable) {
    Logger.w(tag, "Failed to release output buffer for code: ${t.message}")
}

This decouples encoder throughput from downstream/network speed: the codec
buffer pool can never be exhausted by a slow consumer, and the encoder keeps
producing frames into the pool-managed Frame buffers at its own pace.

The one behavioral trade-off is one extra copy of each encoded frame
(ByteBuffer.allocate + put) compared to the previous zero-copy path —
negligible relative to the cost of encoding itself, and the same approach
RootEncoder has shipped for years.

Verified on-device: with uplink bandwidth constrained below the configured
bitrate, encoder output continues uninterrupted (no multi-second gaps in the
muxed output; previously reproducible via byte-level capture). Core unit
tests pass.

…f on Frame.close()

createFrame() held the MediaCodec output ByteBuffer by reference and only
called releaseOutputBuffer() in Frame.onClosed(), which only runs after
the frame has traveled through the whole downstream pipeline including
the network send. MediaCodec's small output buffer pool (~4 buffers)
exhausts if the network send lags (real congestion, not hypothetical),
stalling the encoder for multi-second gaps. Copy the bytes out and
release the codec buffer immediately in createFrame(), decoupling
encoder throughput from downstream/network speed (same approach as
RootEncoder).

Confirmed on-device: encoder output continues uninterrupted under uplink
bandwidth constrained below the configured bitrate, no multi-second gaps
in the captured output (previously reproducible via byte-level capture).
@ThibaultBee

Copy link
Copy Markdown
Owner

As said in the link issue it is not correct fix.

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.

[Bug]: MediaCodecEncoder holds MediaCodec output buffer until fully written downstream, stalling the encoder under network backpressure

2 participants