Memory leak when switching video streams from different cameras over a period of time

Hi,

I am using XProtect Essential+ 2018 R2 with Milestone Mobile Server plugin installed. I have created a simple web application using the MIP Mobile SDK 2018 R2 JavaScript Library. My web application is accessed via Chrome 71.

In my web application, I have 4 camera feeds streaming from different cameras. Every few seconds, the camera feeds will change their video streams to stream from different cameras. After leaving the web application running for 1 day, I noticed the memory footprint and JavaScript memory increased to over 5 times the initial state.

The memory footprint and JavaScript memory did not get released after I close the camera feeds. Below is my HTML code of the camera feeds DOM elements.

HTML

<div id='video1'>
    <canvas width='280' height='210'>
</div>
<div id='video2'>
    <canvas width='280' height='210'>
</div>
<div id='video3'>
    <canvas width='280' height='210'>
</div>
<div id='video4'>
    <canvas width='280' height='210'>
</div>

Note: As I noticed from the sample provided by the mobile server plugin, the received frame is drawn on the canvas, hence my camera feeds only contains the canvas.

Below is my JavaScript code for loading the streams and closing the video streams.

JS

const cameraFeeds = {
    cam1: {
        cameraId: '',
        imageURL: '',
        streamRequest: null,
        videoController: null,
        videoConnectionObserver: null
    },
    cam2: {
        cameraId: '',
        imageURL: '',
        streamRequest: null,
        videoController: null,
        videoConnectionObserver: null
    },
    cam3: {
        cameraId: '',
        imageURL: '',
        streamRequest: null,
        videoController: null,
        videoConnectionObserver: null
    },
    cam4: {
        cameraId: '',
        imageURL: '',
        streamRequest: null,
        videoController: null,
        videoConnectionObserver: null
    }
}
 
// load video streams
function loadVideo( target, cameraId ) {
    let retries = 5;
    const canvas = document.querySelector( `#video${ target } canvas` );
    const canvasContext = canvas.getContext( '2d' );
    let drawing = false;
 
    cameraFeeds[ `cam${ target }` ].videoConnectionObserver = {
        videoConnectionReceivedFrame: videoConnectionReceivedFrame
    };
 
    cameraFeeds[ `cam${ target }` ].streamRequest = XPMobileSDK.requestStream( cameraId, { width: 280, height: 210 }, undefined, requestStreamCallback, requestStreamErrorCallback );
 
    function requestStreamCallback( videoConnection ) {
        if ( videoConnection !== null ) {
            videoConnection.addObserver( cameraFeeds[ `cam${ target }` ].videoConnectionObserver );
            videoConnection.open();
 
            cameraFeeds[ `cam${ target }` ].videoController = videoConnection;
        } else {
            cameraFeeds[ `cam${ target }` ].streamRequest = XPMobileSDK.requestStream( cameraId, { width: 280, height: 210 }, undefined, requestStreamCallback, requestStreamErrorCallback );
        }
    }
 
    function requestStreamErrorCallback( error ) {
        if ( retries > 0 && cameraFeeds[ `cam${ target }` ].cameraId === cameraId ) {
            retries--;
            // timeout to avoid flooding the server
            setTimeout( () => {
                cameraFeeds[ `cam${ target }` ].streamRequest = XPMobileSDK.requestStream( cameraId, { width: 280, height: 210 }, undefined, requestStreamCallback, requestStreamErrorCallback );
            }, 250);
        }
    }
 
    function videoConnectionReceivedFrame( frame ) {
        let image = document.createElement( 'img' );
        image.addEventListener( 'load', onImageLoad );
        image.addEventListener( 'error', onImageError );
        drawing = true;
 
        if ( frame.hasSizeInformation ) {
            let multiplier = frame.sizeInfo.destinationSize.resampling * XPMobileSDK.getResamplingFactor();
            image.width = multiplier * frame.sizeInfo.destinationSize.width;
            image.height = multiplier * frame.sizeInfo.destinationSize.height;
        }
 
        if ( cameraFeeds[ `cam${ target }` ].imageUrl !== '' ) {
            window.URL.revokeObjectURL( cameraFeeds[ `cam${ target }` ].imageUrl );
        }
 
        cameraFeeds[ `cam${ target }` ].imageUrl = window.URL.createObjectURL( frame.blob );
        image.src = cameraFeeds[ `cam${ target }` ].imageUrl;
 
        function onImageLoad( event ) {
            canvas.width = image.width;
            canvas.height = image.height;
            canvasContext.drawImage( image, 0, 0, canvas.width, canvas.height );
            drawing = false;
        }
 
        function onImageError( event ) {
            drawing = false;
            image = null;
        }
    }
}
 
// close video streams
function closeStream( target ) {
    return new Promise( ( resolve, reject ) => {
        const canvas = document.querySelector( `#video${ target } canvas` );
        const canvasContext = canvas.getContext( '2d' );
 
        if ( cameraFeeds[ `cam${ target }` ].videoController !== null && cameraFeeds[ `cam${ target }` ].videoController !== undefined ) {
            cameraFeeds[ `cam${ target }` ].videoController.removeObserver( cameraFeeds[ `cam${ target }` ].videoConnectionObserver );
            XPMobileSDK.closeStream( cameraFeeds[ `cam${ target }` ].videoController.videoId );
            cameraFeeds[ `cam${ target }` ].videoController.destroy();
            cameraFeeds[ `cam${ target }` ].videoController = null;
        }
 
        if ( cameraFeeds[ `cam${ target }` ].streamRequest !== null && cameraFeeds[ `cam${ target }` ].streamRequest !== undefined ) {
            XPMobileSDK.cancelRequest( cameraFeeds[ `cam${ target }` ].streamRequest );
            cameraFeeds[ `cam${ target }` ].streamRequest = null;
        }
 
        window.URL.revokeObjectURL( cameraFeeds[ `cam${ target }` ].imageURL );
        cameraFeeds[ `cam${ target }` ].imageURL = '';
        cameraFeeds[ `cam${ target }` ].cameraId = '';
        cameraFeeds[ `cam${ target }` ].videoConnectionObserver = null;
 
        canvasContext.clearRect( 0, 0, canvas.width, canvas.height );
 
        resolve();
    } );
}

Whenever the camera feeds changes the video stream, it will close the existing stream by calling closeStream. After closeStream function has completed, loadVideo will be called.

I am not sure what might cause the memory leak and why the memory is not being released after stopping and closing all the camera feeds.

Is this the correct way of closing the camera streams and changing the source?

Hi Clinton,

I’m not sure I understand why you included this part of code:

if ( cameraFeeds[ `cam${ target }` ].streamRequest !== null && cameraFeeds[ `cam${ target }` ].streamRequest !== undefined ) {
            XPMobileSDK.cancelRequest( cameraFeeds[ `cam${ target }` ].streamRequest );
            cameraFeeds[ `cam${ target }` ].streamRequest = null;
        }

Seems to me it will not close the stream if the request is not processed by the server right away.

As far as the leak, could you try to recreate the DOM structure (by code) every time you refresh the page/stream.

I’m pretty sure we do not have exactly this use case in Milestone Web Client, as we recreate the whole structure on every change of views.

Hi Clinton,

It seems to me that this may have something to do with the browser itself and this memory leak issue - https://bugs.chromium.org/p/chromium/issues/detail?id=570268

I will investigate further.

Hi all, after reading through the documentation, I made some changes to my code which resulted in lesser memory consumption. However I have yet to test it with the latest chrome version, so the image src bug still persists.

Here are my changes:

HTML

<div id='video1'>
    <img width='280' height='210' />
</div>
<div id='video2'>
    <img width='280' height='210' />
</div>
<div id='video3'>
    <img width='280' height='210' />
</div>
<div id='video4'>
    <img width='280' height='210' />
</div>

JS

// load video streams
function loadVideo( target, cameraId ) {
    let retries = 5;
    let drawing = false;
 
    cameraFeeds[ `cam${ target }` ].videoConnectionObserver = {
        videoConnectionReceivedFrame: videoConnectionReceivedFrame
    };
 
    cameraFeeds[ `cam${ target }` ].streamRequest = XPMobileSDK.requestStream( cameraId, { width: 280, height: 210 }, undefined, requestStreamCallback, requestStreamErrorCallback );
 
    function requestStreamCallback( videoConnection ) {
        if ( videoConnection !== null ) {
            videoConnection.addObserver( cameraFeeds[ `cam${ target }` ].videoConnectionObserver );
            videoConnection.open();
 
            cameraFeeds[ `cam${ target }` ].videoController = videoConnection;
        } else {
            cameraFeeds[ `cam${ target }` ].streamRequest = XPMobileSDK.requestStream( cameraId, { width: 280, height: 210 }, undefined, requestStreamCallback, requestStreamErrorCallback );
        }
    }
 
    function requestStreamErrorCallback( error ) {
        if ( retries > 0 && cameraFeeds[ `cam${ target }` ].cameraId === cameraId ) {
            retries--;
            // timeout to avoid flooding the server
            setTimeout( () => {
                cameraFeeds[ `cam${ target }` ].streamRequest = XPMobileSDK.requestStream( cameraId, { width: 280, height: 210 }, undefined, requestStreamCallback, requestStreamErrorCallback );
            }, 250);
        }
    }
 
    function videoConnectionReceivedFrame( frame ) {
        if ( cameraFeeds[ `cam${ target }` ].videoController.getState() && !drawing && frame.dataSize > 0 ) {
            window.URL.revokeObjectURL( $( `#video${ target } img` ).attr( "src" ) );    // not sure if this helps
 
            $( `#video${ target } img` ).attr( "src", null );
            $( `#video${ target } img` ).attr( "src", "data:image/jpeg;base64," + Base64.encodeArray( frame.data ) );
        }
    }
}
 
// close video streams
function closeStream( target ) {
    return new Promise( ( resolve, reject ) => {
        if ( cameraFeeds[ `cam${ target }` ].videoController !== null && cameraFeeds[ `cam${ target }` ].videoController !== undefined ) {
            cameraFeeds[ `cam${ target }` ].videoController.removeObserver( cameraFeeds[ `cam${ target }` ].videoConnectionObserver );
            XPMobileSDK.closeStream( cameraFeeds[ `cam${ target }` ].videoController.videoId );
            cameraFeeds[ `cam${ target }` ].videoController.destroy();
            cameraFeeds[ `cam${ target }` ].videoController = null;
        }
 
        if ( cameraFeeds[ `cam${ target }` ].streamRequest !== null && cameraFeeds[ `cam${ target }` ].streamRequest !== undefined ) {
            XPMobileSDK.cancelRequest( cameraFeeds[ `cam${ target }` ].streamRequest );
            cameraFeeds[ `cam${ target }` ].streamRequest = null;
        }
 
       
        cameraFeeds[ `cam${ target }` ].imageURL = '';
        cameraFeeds[ `cam${ target }` ].cameraId = '';
        cameraFeeds[ `cam${ target }` ].videoConnectionObserver = null;
         
        window.URL.revokeObjectURL( $( `#video${ target } img` ).attr( "src" ) );    // not sure if this helps
        $( `#video${ target } img` ).attr( "src", null ); 
        $( `#video${ target } img` ).remove();
        $( `#video${ target }` ).append( "<img width='280' height='210' />" );
        resolve();
    } );
}

There were some instances where during the course of the test, it encountered a Response error NotAllowedInThisState ErrorCode 23 from the Milestone Server. And all my streams died. How do I recover my video streams when it encounters this error?

Hi Clinton,

On which command is this error code returned ?

Hi Petar,

It’s from the RequestStream command.

Hm,

Seems very strange at firs look.

This error code is returned usually if client tries to call command on the server after “connect” before “login”.

Obviously this is not your case here.

Another possibility is to be tried to be controlled playback (seek, speed) on the live stream, or PTZ on the playback one.

Which seems to not be your case either.

The strategy that we are applying in mobile client is to try to request the stream more X times (1-3) and after that to try to reconnect (disconnect, connect and login).

In the web client we do not reconnect from security perspective (in order to not save username and password anywhere in the browser).

Hi Clinton,

I tried to reproduce this error with your code but I couldn’t. Do you still get this error?

​Hi Anika,

Do you mean the Response error NotAllowedInThisState ErrorCode 23? I’m still getting this error but I suspect its due to a slow server, as the server I use to host the milestone VMS is quite sluggish.

Yes, that’s the error I tried to reproduce.