Skip to content

Suspected memory leak #239

Closed
Closed

Description

@RabbitHareLu

Hello, thank you for open-sourcing WebRTC-Java.

I encountered some issues during use. My scenario requires starting and stopping audio transmission frequently based on external conditions, necessitating frequent startup and shutdown. During stress testing, I used vmmap to monitor the heap and found that after multiple startups and shutdowns, the heap and blocks kept increasing without stabilizing, leading me to suspect a memory leak.

Environment:
OS: Windows 11 23h2
JDK version: Zulu JDK 25.0.2
Springboot: 3.5.13
Webrtc-java: 0.14.0

Below is my test code:

import com.lsl.core.webrtc.model.WebrtcPushPeerObserver;
import dev.onvoid.webrtc.*;
import dev.onvoid.webrtc.media.audio.*;
import dev.onvoid.webrtc.media.video.CustomVideoSource;
import dev.onvoid.webrtc.media.video.NativeI420Buffer;
import dev.onvoid.webrtc.media.video.VideoFrame;
import dev.onvoid.webrtc.media.video.VideoTrack;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;

import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;

@Slf4j
public class WebrtcPushVoiceService {

    private final ReentrantLock PUSH_VOICE_LOCK = new ReentrantLock();

    private volatile boolean pushVoiceFlag = false;

    private final SignalingClient signalingClient;
    private final AudioDevice audioDevice;

    private AudioDeviceModule audioModule;
    private PeerConnectionFactory factory;
    private RTCPeerConnection peerConnection;

    private AudioTrack audioTrack;
    private VideoTrack videoTrack;

    private CustomVideoSource memoryVideoSource;
    private ScheduledExecutorService dummyVideoExecutor;

    private NativeI420Buffer dummyBlackBuffer;

    private final ExecutorService webrtcPushAsyncExecutor = Executors.newSingleThreadExecutor(
            Thread.ofPlatform().name("webrtc-push-async-thread").factory());

    public WebrtcPushVoiceService(SignalingClient signalingClient, AudioDevice audioDevice) {
        this.signalingClient = signalingClient;
        this.audioDevice = audioDevice;
    }

    public void startPushVoiceStream(String pushUrl) {
        PUSH_VOICE_LOCK.lock();
        try {
            if (pushVoiceFlag) {
                return;
            }

            stopPushInternal();

            pushVoiceFlag = true;

            audioModule = new AudioDeviceModule();
            audioModule.setRecordingDevice(audioDevice);
            audioModule.initRecording();

            factory = new PeerConnectionFactory(audioModule);

            RTCConfiguration config = new RTCConfiguration();

            WebrtcPushPeerObserver observer = new WebrtcPushPeerObserver(
                    pushUrl,
                    signalingClient,
                    () -> webrtcPushAsyncExecutor.submit(this::stopPushVoiceStream));
            peerConnection = factory.createPeerConnection(config, observer);
            observer.setPeerConnection(peerConnection);

            AudioTrackSource audioSource = factory.createAudioSource(new AudioOptions());
            audioTrack = factory.createAudioTrack("audio", audioSource);

            RTCRtpTransceiverInit audioInit = new RTCRtpTransceiverInit();
            audioInit.direction = RTCRtpTransceiverDirection.SEND_ONLY;
            audioInit.streamIds = List.of("rts");
            peerConnection.addTransceiver(audioTrack, audioInit);

            injectMemoryVideoTrackAndOffer();
        } catch (Exception e) {
            stopPushInternal();
        } finally {
            PUSH_VOICE_LOCK.unlock();
        }
    }

    private void injectMemoryVideoTrackAndOffer() {
        try {
            memoryVideoSource = new CustomVideoSource();
            videoTrack = factory.createVideoTrack("dummy_video", memoryVideoSource);

            RTCRtpTransceiverInit videoInit = new RTCRtpTransceiverInit();
            videoInit.direction = RTCRtpTransceiverDirection.SEND_ONLY;
            videoInit.streamIds = List.of("rts");
            peerConnection.addTransceiver(videoTrack, videoInit);

            startDummyVideoLoop();

            createAndSendOffer();
        } catch (Exception e) {
            createAndSendOffer();
        }
    }

    private void startDummyVideoLoop() {
        dummyVideoExecutor = new ScheduledThreadPoolExecutor(1,
                Thread.ofPlatform().name("dummy-video-loop").factory());

        int width = 16;
        int height = 16;

        dummyBlackBuffer = NativeI420Buffer.allocate(width, height);

        ByteBuffer dataY = dummyBlackBuffer.getDataY();
        ByteBuffer dataU = dummyBlackBuffer.getDataU();
        ByteBuffer dataV = dummyBlackBuffer.getDataV();

        byte blackY = 0;
        byte blackUV = (byte) 128;

        while (dataY.hasRemaining())
            dataY.put(blackY);
        while (dataU.hasRemaining())
            dataU.put(blackUV);
        while (dataV.hasRemaining())
            dataV.put(blackUV);

        dummyVideoExecutor.scheduleAtFixedRate(() -> {
            if (!pushVoiceFlag || memoryVideoSource == null || dummyBlackBuffer == null)
                return;
            try {
                dummyBlackBuffer.retain();

                long timestampNs = System.nanoTime();
                VideoFrame frame = new VideoFrame(dummyBlackBuffer, timestampNs);

                memoryVideoSource.pushFrame(frame);

                frame.release();
            } catch (Exception e) {
            }
        }, 0, 33, TimeUnit.MILLISECONDS); // 30 FPS
    }

    private void createAndSendOffer() {
        RTCOfferOptions offerOptions = new RTCOfferOptions();

        peerConnection.createOffer(offerOptions, new CreateSessionDescriptionObserver() {
            @Override
            public void onSuccess(RTCSessionDescription offerDescription) {
                if (!pushVoiceFlag) {
                    return;
                }

                RTCPeerConnection currentPc = peerConnection;
                if (currentPc == null)
                    return;

                currentPc.setLocalDescription(offerDescription, new SetSessionDescriptionObserver() {
                    @Override
                    public void onSuccess() {
                    }

                    @Override
                    public void onFailure(String error) {
                        webrtcPushAsyncExecutor.submit(WebrtcPushVoiceService.this::stopPushVoiceStream);
                    }
                });
            }

            @Override
            public void onFailure(String error) {
                webrtcPushAsyncExecutor.submit(() -> stopPushVoiceStream());
            }
        });
    }

    public void stopPushVoiceStream() {
        PUSH_VOICE_LOCK.lock();
        try {
            stopPushInternal();
        } finally {
            PUSH_VOICE_LOCK.unlock();
        }
    }

    private void stopPushInternal() {
        this.pushVoiceFlag = false;

        if (dummyVideoExecutor != null) {
            dummyVideoExecutor.shutdown();
            try {
                if (!dummyVideoExecutor.awaitTermination(100, TimeUnit.MILLISECONDS)) {
                    dummyVideoExecutor.shutdownNow();
                }
            } catch (InterruptedException e) {
                dummyVideoExecutor.shutdownNow();
                Thread.currentThread().interrupt();
            }
            dummyVideoExecutor = null;
        }

        if (peerConnection != null) {
            RTCPeerConnection tempPc = peerConnection;
            peerConnection = null;
            try {
                tempPc.close();
            } catch (Exception e) {
                log.error("error", e);
            }
        }

        videoTrack = null;
        audioTrack = null;

        if (memoryVideoSource != null) {
            CustomVideoSource tempSource = memoryVideoSource;
            memoryVideoSource = null;
            try {
                tempSource.dispose();
            } catch (Throwable e) {
            }
        }

        if (dummyBlackBuffer != null) {
            try {
                dummyBlackBuffer.release();
                dummyBlackBuffer = null;
            } catch (Exception e) {
            }
        }

        if (factory != null) {
            PeerConnectionFactory tempFactory = factory;
            factory = null;
            try {
                tempFactory.dispose();
            } catch (Throwable t) {
            }
        }

        if (audioModule != null) {
            audioModule.stopRecording();
            AudioDeviceModule tempModule = audioModule;
            audioModule = null;
            try {
                tempModule.dispose();
            } catch (Exception e) {
            }
        }

    }

    @PreDestroy
    public void destroy() {
        stopPushVoiceStream();
        if (!webrtcPushAsyncExecutor.isShutdown()) {
            webrtcPushAsyncExecutor.shutdown();
            try {
                if (!webrtcPushAsyncExecutor.awaitTermination(2, TimeUnit.SECONDS)) {
                    webrtcPushAsyncExecutor.shutdownNow();
                }
            } catch (InterruptedException e) {
                webrtcPushAsyncExecutor.shutdownNow();
                Thread.currentThread().interrupt();
            }
        }
    }
}

Below is my simple stress test code:

public class Test2 {
    static void main() {
        String startUrl = "http://localhost:6666/test/webrtc/push/start?url=?";
        String stopUrl = "http://localhost:6666/test/webrtc/push/stop";

        for (int i = 0; i < 10000; i++) {
            String startResult = HttpUtil.get(startUrl);

            ThreadUtil.sleep(5000);

            String stopResult = HttpUtil.get(stopUrl);
        }
    }
}

Initially, I closed videoTrack audioTrack CustomVideoSource in stopPushInternal, but encountered the exception: "Native object was not deleted. A reference is still around somewhere."

I consulted an AI coding tool, which told me that RTCPeerConnection needed to be closed before closing videoTrack audioTrack CustomVideoSource. Even after changing the order, the exception still occurred during closure: "Native object was not deleted. A reference is still around somewhere."

The AI ​​then told me that I didn't need to explicitly close videoTrack audioTrack; it would automatically close when the factory closed. Therefore, in my test code, I set both videoTrack audioTrack and videoTrack to null.

Activity

  1. RabbitHareLu commented on Apr 21, 2026

    @RabbitHareLu
    Author

    The JVM memory usage appears normal, but Windows Task Manager shows that memory usage is constantly increasing, and vmmap also shows that memory usage is constantly increasing.

  2. RabbitHareLu commented on Apr 21, 2026

    @RabbitHareLu
    Author
    Image Image

    The startPushVoiceStream and stopPushVoiceStream functions were called repeatedly, over 2000 times. Windows Explorer showed that the memory usage had increased from over 200MB to over 700MB.

  3. devopvoid commented on Sep 13, 2026

    @devopvoid
    Owner

    Thanks for the very detailed report and stress test.

    I'm fairly confident this is now fixed by #283, which just landed on main. Before that PR, RTCPeerConnection.close() called pc->Close() but never called pc->Release() — so the native PeerConnectionInterface was never actually freed on close, every single time. In your stress test, that means every start/stop cycle leaked the entire native peer connection object graph (transports, RTP senders/receivers, media engine state), which matches the unbounded growth you saw.

    I think this also explains the second symptom you ran into. When you tried disposing videoTrack/audioTrack after closing the peer connection and still got "Native object was not deleted. A reference is still around somewhere.", that's because the peer connection itself was never actually released — it (and its internal RTP senders) were still alive in the background, still holding a reference to your tracks, even after close() returned. With pc->Release() now happening on close, closing the peer connection first should actually tear down its internal senders/receivers, and disposing the tracks afterward should then succeed instead of throwing.

    One thing to flag from your final workaround: this library has no finalizer/Cleaner — disposal is entirely manual. If you go back to explicitly disposing audioTrack/videoTrack/memoryVideoSource/audioSource (in the order: close the peer connection, then dispose the tracks/sources you created, then dispose the factory), those native objects will actually be freed instead of leaking every cycle, and it should no longer throw now that the ordering issue is fixed.

    0.14.0 predates this fix by a good number of releases, so you'd need to build from main (or wait for the next release) to verify. Would be great if you could re-run your stress test against that and confirm the leak is gone — happy to keep this open until then.

  4. RabbitHareLu commented on Sep 15, 2026

    @RabbitHareLu
    Author

    Thank you very much for taking the time to maintain this project. Today I updated to version 0.18.0 and ran stress tests, and I did not notice any obvious memory growth behavior. However, I used GPT 6 to help review the code. The AI pointed out that AudioTrackSource does not have a dispose method, and GPT said that createAudioSource may cause a leak. Could you please help confirm this issue? Thanks again.

  5. devopvoid commented on Sep 15, 2026

    @devopvoid
    Owner

    Yes, every call to factory.createAudioSource(...) leaks one native AudioSourceInterface ref for the life of the process — confirming your stress-test symptom, and confirming GPT's diagnosis.

  6. devopvoid commented on Sep 15, 2026

    @devopvoid
    Owner

    @RabbitHareLu Good catch, and GPT is right on this one.

    PeerConnectionFactory.createAudioSource() hands back an AudioTrackSource wrapping a ref-counted native AudioSourceInterface. Every other ref-counted wrapper returned from the API (AudioTrack, VideoTrack, MediaStream, etc.) has a dispose() that releases its native reference, but AudioTrackSource never got one — so the reference obtained from createAudioSource() could never be released from Java. It leaked one native audio source per call, regardless of whether the resulting track, peer connection, or factory were later disposed.

    Fixed in #287, which adds AudioTrackSource.dispose(). After upgrading, call audioSource.dispose() once you no longer need it (safe to call even while a track built from it is still active, since it's ref-counted).

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions