Skip to content

Excessive targetBufferSize in DefaultLoadControl Can Lead to OOM #2860

@cucumbersw

Description

@cucumbersw

Version

Media3 1.8.0

More version details

No response

Devices that reproduce the issue

Pixel6Pro Android 15

Devices that do not reproduce the issue

No response

Reproducible in the demo app?

Yes

Reproduction steps

Affected Component: ExoPlayer / DefaultLoadControl

📝 Description of the Problem

I have observed a potential scenario where DefaultLoadControl.calculateTargetBufferBytes() returns an excessively large target buffer size, which can lead to OutOfMemory (OOM) errors, especially when playing high-bitrate content on resource-constrained devices.

The issue is related to how the load control calculates the required buffer for multiple tracks, combined with how certain non-standard tracks are classified during track selection.

Detailed Scenario

  • High-Bitrate Content: A large MP4 file with a very high-resolution and high-bitrate video track is being played.

  • Uncommon Track: The file includes a private or non-standard Metadata track (e.g., custom format).

  • Track Misclassification: During the TrackSelection process, because the metadata track is of an unknown or non-standard type, it defaults to being classified based on the container's MIME type. In this specific case, it may be incorrectly treated as a second Video track.

  • Excessive Buffer Calculation: The DefaultLoadControl then calculates the required buffer size based on two "video" tracks and one audio track. Given the already high bitrate of the actual video, and the default long buffer duration (e.g., 50 seconds), the resulting targetBufferSize becomes prohibitively large (e.g., exceeding 256MB).

Consequence: This aggressive and potentially incorrect memory reservation rapidly consumes the heap, causing an OOM error.

💡 Proposed Solution

The root cause (track misclassification) may be hard to fix generically for all custom metadata tracks, but the OOM outcome can be easily mitigated by introducing a safeguard in the DefaultLoadControl.

I propose adding a maximum hard cap to the final calculated buffer size within the calculateTargetBufferBytes() method.

For example, if a constant MAX_BUFFER_SIZE (e.g., 128MB or a suitably determined value) is defined, the method should ensure the return value never exceeds this limit:

// Inside DefaultLoadControl.calculateTargetBufferBytes()
// ... existing calculation logic ...

int targetBufferSize = // calculated value

// Add a check to prevent excessive buffer allocation
if (targetBufferSize > MAX_BUFFER_SIZE) {
    return MAX_BUFFER_SIZE;
}

return targetBufferSize;

This simple fix would prevent runaway memory allocation due to edge-case track misclassification, offering better stability without sacrificing the buffering performance of well-formed streams.

Expected result

No OOM error

Actual result

OOM crash

Media

You can simply create a MP4 containing 4K resolution and 64mbps video, with a metadata track of mimetype "application/xyz_private" and an audio track. When play the MP4 with ExoPlayer, add a dummy metadata renderer to make sure the metadata track is also selected.


@SuppressLint("UnsafeOptInUsageError")
fun startPlayer(playerView: PlayerView, uri: Uri) {

    if (exoPlayer == null) {
        val renderersFactory = MetaRenderersFactory(this)
        exoPlayer = ExoPlayer.Builder(this, renderersFactory)
            .build().apply {
            addAnalyticsListener(EventLogger())
            playWhenReady = true
            repeatMode = Player.REPEAT_MODE_ONE
        }
    }

    playerView.player = exoPlayer!!
    val mediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(this))
        .createMediaSource(MediaItem.fromUri(uri))
    exoPlayer!!.setMediaSource(mediaSource)
    exoPlayer!!.prepare()
}

@UnstableApi
data class DummyMetaFrame(val mimeType:String, val data: ByteBuffer, val timeUs: Long): Metadata.Entry {
    override fun getWrappedMetadataFormat(): Format? = null //return null indicates no wrapped metadata need to be parsed
    override fun getWrappedMetadataBytes(): ByteArray? = data.toByteArray()
}

@UnstableApi
class DummyMetadataDecoder(private val mimeType: String): MetadataDecoder {
    companion object {
        val factory = object: MetadataDecoderFactory {
            override fun supportsFormat(format: Format) = ("application/audio_meta" == format.sampleMimeType)
            override fun createDecoder(format: Format) = DummyMetadataDecoder(format.sampleMimeType?:"UnknownMime")
        }
    }

    override fun decode(inputBuffer: MetadataInputBuffer): Metadata {
        return Metadata(DummyMetaFrame(mimeType, inputBuffer.data!!, inputBuffer.timeUs))
    }
}

@UnstableApi
class MetaRenderersFactory(context: Context): DefaultRenderersFactory(context) {
    override fun buildMetadataRenderers(
        context: Context,
        output: MetadataOutput,
        outputLooper: Looper,
        extensionRendererMode: Int,
        out: ArrayList<Renderer>
    ) {
        super.buildMetadataRenderers(context, output, outputLooper, extensionRendererMode, out)
        //Add metadata renderer to be the leading No.1 in the list
        out.add(0,
            MetadataRenderer(
                DummyMetaOutput("DummyMeta"),
                null,
                DummyMetadataDecoder.factory,
                true
            )
        )
    }
}

Bug Report

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions