memory leak in JavaScript (WebWorker, Canvas, IndexedDB)

1.1k views Asked by At

I need some help with finding a memory leak in a small, Browser / WebWorker JavaScript. I tracked it down into this little piece of code:

    /**
     * Resizes an Image
     *
     * @function scaleImage
     * @param {object}  oImageBlob  blob of image
     * @param {int}     iHeight     New height of Image
     * @return {ImageBitmap}    ImageBitmap Object
     */         
    async function scaleImage(oImageBlob, iHeight) {
        var img = await self.createImageBitmap(oImageBlob); 
        var iWidth = Math.round( ( img.width / img.height ) * iHeight); 
        var canvas = new OffscreenCanvas(iWidth,iHeight);
        var ctx = canvas.getContext('2d');  
        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);      
        return(canvas.transferToImageBitmap());
    }

It's called from:

    [inside a web worker: Some looping that calls this about 1200 times while parsind files from a s3 bucket ...]
         var oImageBlob = await new Response(oS3Object.Body, {}).blob();
         var oThumbnail = await scaleImage(oImageBlob, 100);
         await IDBputData(oInput.sFileKey, oImageBlob, oInput.sStore, oThumbnail)
    [... end of the loop]

The other interior function is

    /**
     * Put information to IndexedDB 
     *
     * @function IDBputData 
     * @param {string}  sKey        key of information
     * @param {string}  sValue      information to upload
     * @param {string}  sStore      name of object store
     * @param {object}  oThumbnail  optrional, default: null, thumbnail image
     * @return {object}     - SUCCESS: array, IndexedDB Identifyer "key"
     *                      - FAIL: Error Message
     */     
    async function IDBputData(sKey, sValue, sStore, oThumbnail=null) {
        var oGeneratedKeys = {};
        if(sStore=="panelStore"){
            oGeneratedKeys = await getKeyfromSKey(sKey);
        }
        return new Promise((resolve, reject) => {
            const tx = oConn.transaction(sStore, 'readwrite');                  
            const store = tx.objectStore(sStore);
            var request = {}
            request = store.put({panelkey: oGeneratedKeys.panelkey, layerkey: oGeneratedKeys.layerkey, countrycode: oGeneratedKeys.countrycode, value: sValue, LastModified: new Date(), oThumbnail: oThumbnail});
            request.onsuccess = () => (oThumbnail.close(),resolve(request.result));
            request.onerror = () => (oThumbnail.close(),reject(request.error));
        });
    }

When I run it this way in Chrome, it will consume every bit of RAM I've got free (around 8 GB) and then crash. (Laptop with shared RAM for CPU/GPU).

When I change

         var oThumbnail = await scaleImage(oImageBlob, 100);

to

         var oThumbnail = null;

RAM consumption of Chrome stays rather fixed around 800 MB, so there must be something with the topmost function "scaleImage".

I tried tweaking it, but with no success.

/**
 * Resizes an Image
 *
 * @function scaleImage
 * @param {object}  oImageBlob  blob of image
 * @param {int}     iHeight     New height of Image
 * @return {ImageBitmap}    ImageBitmap Object
 */         
async function scaleImage(oImageBlob, iHeight) {
    var img = await self.createImageBitmap(oImageBlob); 
    var iWidth = Math.round( ( img.width / img.height ) * iHeight); 
    var canvas = new OffscreenCanvas(iWidth,iHeight);
    var ctx = canvas.getContext('2d');  
    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);      

    var oImageBitmap = canvas.transferToImageBitmap();

    ctx = null;
    canvas = null;
    iWidth = null;
    img = null;

    return(oImageBitmap);
}

Any help is very much appreciated.

1

There are 1 answers

9
Kaiido On BEST ANSWER

For the ImageBitmap to release its bitmap data the most efficient way, you have to call its .close() method once you're done with it.

But actually, you don't need this scaleImage function. createImageBitmap() has a resizeHeight option, and if you use it without the resizeWidth one, you'll resize your image by keeping the aspect-ratio exacty like you are doing in your function, except that it won't need to assign the bitmap twice.

Once you have this resized ImageBitmap, you can transfer it to a BitmapRenderingContext (which will internally close() the original ImageBitmap) and call the transferToBlob() from that renderer. This should be lighter for your computer.

async function worker_script() {
  const blob = await fetch( "https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png" ).then( (resp) => resp.blob() );
  // resize from createImageBitmap directly
  const img = await createImageBitmap( blob , { resizeHeight: 100 } );
  const canvas = new OffscreenCanvas( img.width, img.height );
  canvas.getContext( "bitmaprenderer" )
    .transferFromImageBitmap( img ); // this internally closes the ImageBitmap
  const resized_blob = await canvas.convertToBlob();
  // putInIDB( resized_blob );
  // for the demo we pass that Blob to main, but one shouldn't have to do that
  // show the canvas instead ;)
  postMessage( { blob: resized_blob, width: img.width } );
  // to be safe, we can even resize our canvas to 0x0 to free its bitmap entirely
  canvas.width = canvas.height = 0;
}

// back to main
const worker = new Worker( getWorkerURL() );
worker.onmessage = ({ data: { blob, width } }) => {
  const img = new Image();
  img.src = URL.createObjectURL( blob );
  img.onload = (evt) => URL.revokeObjectURL( img.src );
  document.body.append( img );
  console.log( "ImageBitmap got detached?", width === 0 );
};

function getWorkerURL() {
  const content = "!" + worker_script.toString() + "();";
  const blob = new Blob( [ content ], { type: "text/javascript" } );
  return URL.createObjectURL( blob );
}