Search code examples
javascriptweb-workerpostmessage

Why is MessagePort.postMessage crashing Firefox?


I submitted a crash report to Firefox, but I would also like to make sure that I did not write something wrong or forbidden by the spec.

In the snippet below:

  • The main process creates a web worker
  • The main process creates a MessageChannel
  • The main process sends port2 to the web worker
  • The web worker acknowledges the reception of the port
  • The main process sends a message on the channel to request actual work
  • The web worker creates 1000 ArrayBuffers and transfers them back

As stated in the title, this code crashes Firefox for me pretty much every time. I tried to send a different amount of buffers, and it seems like the watershed is at about ~170 buffers. Specifically, I had the impression that up to 171, Firefox does not crash, but between 171-174 things get weird (as in the window becomes unresponsive, of nothing comes back from the worker) and at 175 it always crashes.

Is my code wrong or is this a Firefox bug/limitation?

Chrome, Edge and Safari seem to be okay with the code.

addEventListener("load", () => {
  const workerSrc = document.getElementById("worker-src").innerText;
  const src = URL.createObjectURL(new Blob([workerSrc], { type: "application/javascript" }));

  const btn = document.createElement("button");
  btn.innerText = "Click me!";
  btn.addEventListener("click", () => {
    const worker = new Worker(src);
    const channel = new MessageChannel();

    channel.port1.addEventListener("message", (message) => {
      if (message.data.name === "messagePortResult") {
        channel.port1.postMessage({ name: "getBuffers" });
      } else if (message.data.name === "getBuffersResult") {
        console.log("This is what I got back from the worker: ", message.data.data);
      }
    });

    channel.port1.start();

    worker.postMessage({ name: "messagePort", port: channel.port2 }, [channel.port2]);
  });

  document.body.appendChild(btn);
});
<script id="worker-src" type="x-js/x-worker">
  let port = null;

  addEventListener("message", (message) => {
    if (message.data.name === "messagePort") {
      port = message.data.port;

      port.addEventListener("message", () => {
        const buffers = [];

        for (let i = 0; i < 1000; i++) {
          buffers.push(new ArrayBuffer(1024));
        }

        port.postMessage({ name: "getBuffersResult", data: buffers }, buffers);
      });

      port.start();

      port.postMessage({ name: "messagePortResult" });
    }
  });
</script>


Solution

  • This is definitely a bug, and you are not doing anything "against the specs" no, your code "should" work.

    You did very well opening this issue, in my experience these get treated faster than just crash reports and indeed it's already fixed after three days.

    By the way, I made a simpler repro which doesn't use a Worker:

    button.onclick = (evt) => {
      const { port1 } = new MessageChannel();
      const buffers = [];
      for( let i = 0; i<1000; i++ ) {
        buffers.push( new ArrayBuffer( 1024 ) );
      }
      port1.postMessage( buffers, buffers );
    };
    <button id="button">Crash Firefox Tab</button>


    That being said, it is probably possible for you to workaround this bug.

    • This bug only concerns MessageChannel's MessagePorts. Maybe you could rewrite your code so that you keep using the Worker's MessagePorts instead:

    const worker_content = `
      const buffers = [];
      for( let i = 0; i<1000; i++ ) {
        buffers.push( new ArrayBuffer( 1024 ) );  
      }
      postMessage( { data: buffers }, buffers );
    `;
    const worker_url = URL.createObjectURL( new Blob( [ worker_content ] ) );
    worker = new Worker( worker_url );
    worker.onmessage = (evt) => {
      console.log( "received", evt.data );
    };

    • Transferring that many ArrayBuffers sounds a bit weird anyway. I'm not sure why you need to do this, but one way around until SharedArrayBuffers come back to play is to transfer a single big ArrayBuffer, and create many "sub-arrays" from it.
      This way you can keep working on these as if they were many small arrays, while there is actually still a single underlying ArrayBuffer and GC doesn't have to kick in when doing many IO between both envs.
      I didn't really check, but I'd assume this will be faster than transferring that many small buffers in any browser.

    const nb_of_buffers = 1000;
    const size_of_buffers = 1024;
    const { port1, port2 } = new MessageChannel();
    {
      // in your main thread
      port1.onmessage = (evt) => {
        const big_arr = evt.data;
        const size_of_array = size_of_buffers / big_arr.BYTES_PER_ELEMENT;
        const arrays = [];
        for( let i = 0; i < nb_of_buffers; i++) {
          const start = i * size_of_array;
          const end = start + size_of_array;
          arrays.push( big_arr.subarray( start, end ) );
        }
        console.log( "received %s arrays", arrays.length );
        console.log( "first array", arrays[ 0 ] );
        console.log( "last array", arrays[ arrays.length - 1 ] );
        console.log( "same buffer anyway?", arrays[ 0 ].buffer === arrays[ arrays.length - 1 ].buffer );
      };
    }
    {
      // in Worker
      const big_buffer = new ArrayBuffer( 1024 * 1000 );
      const big_arr = new Uint32Array( big_buffer );
      const size_of_array = size_of_buffers / big_arr.BYTES_PER_ELEMENT;
      const arrays = [];
      for( let i = 0; i < nb_of_buffers; i++) {
        const start = i * size_of_array;
        const end = start + size_of_array;
        const sub_array = big_arr.subarray( start, end );
        arrays.push( sub_array );
        sub_array.fill( i );
      }
      // transfer to main
      port2.postMessage( big_arr, [big_buffer] );
      console.log( "sub_arrays buffer got transferred?",
        arrays.every( arr => arr.buffer.byteLength === 0 )
      );
    }

    • In case you really need that many ArrayBuffers, you could create copies in each thread and use a single big ArrayBuffer only for transferring, filling the smaller ones from that big ArrayBuffer. This means you'll constantly have the data stored thrice in memory, but no new data is ever created after, and GC doesn't have to kick in.

    const nb_of_buffers = 1000;
    const size_of_buffers = 1024;
    const { port1, port2 } = new MessageChannel();
    {
      // in your main thread
      const buffers = [];
      for( let i = 0; i < nb_of_buffers; i++) {
        buffers.push( new ArrayBuffer( size_of_buffers ) );
      }
      port1.onmessage = (evt) => {
        const transfer_arr = new Uint32Array( evt.data );
        // update the values of each small arrays
        buffers.forEach( (buf, index) => {
          const size_of_arr = size_of_buffers / transfer_arr.BYTES_PER_ELEMENT;
          const start = index * size_of_arr;
          const end = start + size_of_arr;
          const sub_array = transfer_arr.subarray( start, end );
          new Uint32Array( buf ).set( sub_array );
        } );
        console.log( "first array", new Uint32Array( buffers[ 0 ] ) );
        console.log( "last array", new Uint32Array( buffers[ buffers.length - 1 ] ) );
      };
    }
    {
      // in Worker
      const buffers = [];
      for( let i = 0; i < nb_of_buffers; i++) {
        const buf = new ArrayBuffer( size_of_buffers );
        buffers.push( buf );
        new Uint32Array( buf ).fill( i );
      }
      // copy inside big_buffer
      const big_buffer = new ArrayBuffer( size_of_buffers * nb_of_buffers );
      const big_array = new Uint32Array( big_buffer );
      buffers.forEach( (buf, index) => {
        const small_array = new Uint32Array( buf );
        const size_of_arr = size_of_buffers / small_array.BYTES_PER_ELEMENT;
        const start = index * size_of_arr;
        big_array.set( small_array, start );
      } );
      // transfer to main
      port2.postMessage( big_buffer, [ big_buffer ] );
    }