How to support "optimized" 64 MiB JSON parsing and streaming for Native Messaging host?

I have been recently rewriting the Native Messaging hosts I’ve written and contributed to developing to support 64 MiB processing.

This is the Native Messaging protocol

Native messaging protocol (Chrome Developers)

Chrome starts each native messaging host in a separate process and communicates with it using standard input (stdin) and standard output (stdout). The same format is used to send messages in both directions; each message is serialized using JSON, UTF-8 encoded and is preceded with 32-bit message length in native byte order. The maximum size of a single message from the native messaging host is 1 MB, mainly to protect Chrome from misbehaving native applications. The maximum size of the message sent to the native messaging host is 64 MiB.

So far I have completed 64 MiB processing support for Node.js (the same code can be used for Deno and Bun), QuickJS, Bytecode Alliance’s Javy, AssemblyScript, Rust.

I still have C, C++, Static Hermes, Bash, V8, SpiderMonkey, Amazon Web Services LLRT, txiki.js, and Python to do to complete 64 MiB support for the languages, engines, runtimes I’ve written that currently have 1 MiB support.

I started rewriting the algorithm parsing the JSON manually (encoded in u8) in AssemblyScript because there’s no JSON global object. I then ported that algorithm to QuickJS. Somewhere along the way somebody on Discord posted the algorithm I wrote to Google’s Gemini program. The performance improvements to the algorithm are clearly measurable using the code Gemini program spit out.

Instead of asking Google Gemini to spit out the algorithm I wrote “optimized“ in Python, I’ll ask the humans who write Python, first.

Here’s what I have right now, that only handles 1 MiB of input

#!/usr/bin/env -S python3 -u
# https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging
# https://github.com/mdn/webextensions-examples/pull/157
# Note that running python with the `-u` flag is required on Windows,
# in order to ensure that stdin and stdout are opened in binary, rather
# than text, mode.

import sys
import json
import struct
import traceback

try:
    # Python 3.x version
    # Read a message from stdin and decode it.
    def getMessage():
        rawLength = sys.stdin.buffer.read(4)
        # if len(rawLength) == 0:
        #    sys.exit(0)
        messageLength = struct.unpack('@I', rawLength)[0]
        message = sys.stdin.buffer.read(messageLength).decode('utf-8')
        return json.loads(message)

    # Encode a message for transmission,
    # given its content.
    def encodeMessage(messageContent):
        # https://stackoverflow.com/a/56563264
        # https://docs.python.org/3/library/json.html#basic-usage
        # To get the most compact JSON representation, you should specify 
        # (',', ':') to eliminate whitespace.
        encodedContent = json.dumps(messageContent, separators=(',', ':')).encode('utf-8')
        encodedLength = struct.pack('@I', len(encodedContent))
        return {'length': encodedLength, 'content': encodedContent}

    # Send an encoded message to stdout
    def sendMessage(encodedMessage):
        sys.stdout.buffer.write(encodedMessage['length'])
        sys.stdout.buffer.write(encodedMessage['content'])
        sys.stdout.buffer.flush()
        
    while True:
        receivedMessage = getMessage()
        sendMessage(encodeMessage(receivedMessage))

except Exception as e:
    sys.stdout.buffer.flush()
    sys.stdin.buffer.flush()
    # https://discuss.python.org/t/how-to-read-1mb-of-input-from-stdin/22534/14
    with open('nm_python.log', 'w', encoding='utf-8') as f:
        traceback.print_exc(file=f)
    sys.exit(0)

This is what I do in QuickJS - including the “optimization“ that Google Gemini spit out

#!/usr/bin/env -S /home/user/bin/qjs -m --std
// QuickJS Native Messaging host
// guest271314, 5-6-2022

function getMessage() {
const header = new Uint32Array(1);
std.in.read(header.buffer, 0, 4);
const output = new Uint8Array(header[0]);
const len = std.in.read(output.buffer, 0, output.length);
return output;
}
// 

function sendMessage(message) {
// Constants for readability
const COMMA = 44;
const OPEN_BRACKET = 91; // [
const CLOSE_BRACKET = 93; // ]
const CHUNK_SIZE = 1024 * 1024; // 1MB

// If small enough, send directly (Native endianness handling recommended)
if (message.length <= CHUNK_SIZE) {
const header = new Uint8Array(4);
header[0] = (message.length >> 0) & 0xff;
header[1] = (message.length >> 8) & 0xff;
header[2] = (message.length >> 16) & 0xff;
header[3] = (message.length >> 24) & 0xff;

// Two writes are often better than allocating a new joined buffer
// if the engine supports it. If not, combine them.
const output = new Uint8Array(4 + data.length);
output.set(header, 0);
output.set(data, 4);
std.out.write(output.buffer, 0, output.length);
std.out.flush();
return;

}

let index = 0;

// Iterate through the message until we reach the end
while (index < message.length) {
let splitIndex;

// 1. Determine where to cut the chunk
// Try to jump forward 1MB
let searchStart = index + CHUNK_SIZE - 8;

if (searchStart >= message.length) {
  // We are near the end, take everything remaining
  splitIndex = message.length;
} else {
  // Find the next safe comma to split on
  splitIndex = message.indexOf(COMMA, searchStart);
  if (splitIndex === -1) {
    splitIndex = message.length; // No more commas, take the rest
  }
}

// 2. Extract the raw chunk (No copy yet, just a view)
const rawChunk = message.subarray(index, splitIndex);

// 3. Prepare the final payload buffer
// We calculate size first to allocate exactly once per chunk
const startByte = rawChunk[0];
const endByte = rawChunk[rawChunk.length - 1];

let prepend = null;
let append = null;

// Logic to ensure every chunk is a valid JSON array [...]
// Case A: Starts with '[' (First chunk), needs ']' at end if not present
if (startByte === OPEN_BRACKET && endByte !== CLOSE_BRACKET) {
  append = CLOSE_BRACKET;
} // Case B: Starts with ',' (Middle chunks), needs '[' at start
else if (startByte === COMMA) {
  prepend = OPEN_BRACKET;

  // If it doesn't end with ']', it needs one
  if (endByte !== CLOSE_BRACKET) {
    append = CLOSE_BRACKET;
  }
  // Note: We skip the leading comma in the raw copy later by offsetting
}

// 4. Construct the output buffer
// Calculate final length: Header (4) + (Prepend?) + Body + (Append?)
// Note: If startByte was COMMA, we usually want to overwrite it with '[',
// but your original logic kept the comma data or shifted.
// Standard approach:
// If raw starts with comma, we replace comma with '[' or insert '['?
// Your logic: Replaced [0] if it was comma.

// Optimized construction based on your logic pattern:
let bodyLength = rawChunk.length;
let payloadOffset = 4; // Start after 4-byte header

// Adjust sizes based on brackets
const hasPrepend = prepend !== null;
const hasAppend = append !== null;

// Special handling for the "Comma Start" case to match your logic:
// Your logic: x[0] = 91; x[i] = data[i]. Effectively replaces comma with '['
let sourceOffset = 0;
if (startByte === COMMA) {
  sourceOffset = 1; // Skip the comma from source
  bodyLength -= 1; // Reduce source len
  // We implicitly assume we prepend '[' in this slot
}

const totalLength = 4 + (hasPrepend ? 1 : 0) + bodyLength +
  (hasAppend ? 1 : 0);
const output = new Uint8Array(totalLength);

// Write Length Header (Little Endian example)
const dataLen = totalLength - 4;
output[0] = (dataLen >> 0) & 0xff;
output[1] = (dataLen >> 8) & 0xff;
output[2] = (dataLen >> 16) & 0xff;
output[3] = (dataLen >> 24) & 0xff;

// Write Prepend (e.g. '[')
let cursor = 4;
if (hasPrepend) {
  output[cursor] = prepend;
  cursor++;
} else if (startByte === COMMA) {
  // If we didn't flag prepend but stripped comma, likely need bracket
  // Based on your specific logic "x[0] = 91", we treat that as a prepend
  output[cursor] = OPEN_BRACKET;
  cursor++;
}

// Write Body (Fast copy)
// We use .set() which is much faster than a loop
output.set(rawChunk.subarray(sourceOffset), cursor);
cursor += bodyLength;

// Write Append (e.g. ']')
if (hasAppend) {
  output[cursor] = append;
}

// 5. Send immediately
std.out.write(output.buffer, 0, output.length);
std.out.flush();

// Force GC only occasionally if needed (every chunk is often too frequent)
std.gc();

// Move index for next iteration
index = splitIndex;

}
}

function main() {
while (true) {
const message = getMessage();
sendMessage(message);
}
}

try {
main();
} catch (e) {
// std.writeFile(“err.txt”, e.message);
std.exit(0);
}

which is based on this code I wrote in QuickJS

function sendMessage(message) {
if (message.length > 1024 ** 2) {
const json = message;
const data = new Array();
let fromIndex = 1024 ** 2 - 8;
let index = 0;
let i = 0;
do {
i = json.indexOf(44, fromIndex);
const arr = json.subarray(index, i);
data.push(arr);
index = i;
fromIndex += 1024 ** 2 - 8;
} while (fromIndex < json.length);
if (index < json.length) {
data.push(json.subarray(index));
}
for (let j = 0; j < data.length; j++) {
const start = data[j][0];
const end = data[j][data[j].length - 1];
if (start === 91 && end !== 44 && end !== 93) {
const x = new Uint8Array(data[j].length + 1);
for (let i2 = 0; i2 < data[j].length; i2++) {
x[i2] = data[j][i2];
}
x[x.length - 1] = 93;
data[j] = x;
}
if (start === 44 && end !== 93) {
const x = new Uint8Array(data[j].length + 1);
x[0] = 91;
for (let i2 = 1; i2 < data[j].length; i2++) {
x[i2] = data[j][i2];
}
x[x.length - 1] = 93;
data[j] = x;
}
if (start === 44 && end === 93) {
const x = new Uint8Array(data[j].length);
x[0] = 91;
for (let i2 = 1; i2 < data[j].length; i2++) {
x[i2] = data[j][i2];
}
data[j] = x;
}
}
for (let k = 0; k < data.length; k++) {
const arr = data[k];
const header = Uint32Array.from(
{
length: 4,
},
(_, index) => (arr.length >> (index * 8)) & 0xff,
);
const output = new Uint8Array(header.length + arr.length);
output.set(header, 0);
output.set(arr, 4);
std.out.write(output.buffer, 0, output.length);
std.out.flush();
std.gc();
}
} else {
const header = Uint32Array.from({
length: 4,
}, (_, index) => (message.length >> (index * 8)) & 0xff);
const output = new Uint8Array(header.length + message.length);
output.set(header, 0);
output.set(message, 4);
std.out.write(output.buffer, 0, output.length);
std.out.flush();
std.gc();
}
}

How would you write the above algorithm in Python?


The old 1MiB limit only ever applied on the Browser side, not the app side. Anyway, if you’ve got C and C++ versions already, why not make a C extension for Python based on them?

Writing code is very satisfying, but is there really any more to the app side for a pure Python version, than serialising to json, and piping the text to stdin or stdout? The Python examples in MDN from further down in the link already given, are nice and short, even if they don’t use the native support for JSON: Native messaging - Mozilla | MDN

The old 1MiB limit only ever applied on the Browser side, not the app side.

The 1 MiB limit per message is from host to browser. That has not changed, ever, I don’t think. What changed recently was the maximum message length from browser to host. It used to be 4 GiB. Now it’s 64 MiB.

The question is about supporting 64 MiB from browser to host, in Python.

Python never enforced any such 1MiB limit in the first place (at least not to my knowledge).

Which project, libraries, or host apps is this code for?

I don’t think you are understanding the question. AFAICT nobody in the wild had implemented processing 4 GiB or the newer 64 MiB maximum message size processing back to the browser where we can only send 1 MiB at a time to the browser back from the host.

So, browser sends 64 MiB. In the host that 64 MiB of JSON has to be parsed, and in the basic case echoed back at 1 MiB per message to the browser. 64 MiB cannot be sent back all in the same single message.

So what I’m doing is going through all of the hosts I have written or contributed to GitHub - guest271314/NativeMessagingHosts: Native Messaging hosts and implementing parsing 64 MiB of JSON from the browser, and sending back 1 MiB of valid JSON to the browser.

I havn’t implemented the C or C++ versions, yet. I have implemented that for JavaScript, AssemblyScript, and Rust, so far.

I’m asking how to do that in Python, ideally using only the standard library.

Basically, if encodedLength is greater than 1024\*1024, break up that JSON into 1 MiB, and send each back to the browser. For simplicity we are only expecting and parsing JSON Array’s. So the input will always be in the encoded form [91,…,44,…93].

encodedContent = json.dumps(messageContent, separators=(',', ':')).encode('utf-8')
encodedLength = struct.pack('@I', len(encodedContent))

It’s highly possible I don’t understand something.
It’s also possible nobody’s implemented " 4 GiB" in Python, because nobody’s enforced that arbitrary limit in the first place

It’s also possible nobody’s implemented " 4 GiB" in Python, because nobody’s enforced that arbitrary limit in the first place

No idea what you’re talking about. Thanks, anyway.

I went ahead and picked a random JavaScript to Python “code converter“, input the Node.js version (which also runs using Deno and Bun JavaScript runtimes) of the same algorithm used with QuickJS, “optimized” via somebody on Discord feeding my original code (and the conversation we were having) to one of Google’s programs, see guest271314 vs. Gemini · GitHub

#!/usr/bin/env -S UV_THREADPOOL_SIZE=1 /home/user/bin/node --optimize-for-size --zero-unused-memory --memory-saver-mode --double-string-cache-size=1 --experimental-flush-embedded-blob-icache --jitless --expose-gc --v8-pool-size=1
// Node.js Native Messaging host
// guest271314, 10-9-2022

// /home/user/bin/deno -A --v8-flags="--expose-gc" --unstable-bare-node-builtins
// /home/user/bin/bun --expose-gc

// https://github.com/nodejs/node/issues/11568#issuecomment-282765300
process.stdout?._handle?.setBlocking(false);

const ab = new ArrayBuffer(0, { maxByteLength: 1024 ** 2 * 64 });
let totalMessageLength = 0;
let currentMessageLength = 0;

// https://gist.github.com/guest271314/c88d281572aadb2cc6265e3e9eb09810
function sendMessage(message) {
  // Constants for readability
  const COMMA = 44;
  const OPEN_BRACKET = 91; // [
  const CLOSE_BRACKET = 93; // ]
  const CHUNK_SIZE = 1024 * 1024; // 1MB

  // If small enough, send directly (Native endianness handling recommended)
  if (message.length <= CHUNK_SIZE) {
    process.stdout.write(new Uint32Array([message.length]));
    process.stdout.write(message);
    return;
  }

  let index = 0;

  // Iterate through the message until we reach the end
  while (index < message.length) {
    let splitIndex;

    // 1. Determine where to cut the chunk
    // Try to jump forward 1MB
    let searchStart = index + CHUNK_SIZE - 8;

    if (searchStart >= message.length) {
      // We are near the end, take everything remaining
      splitIndex = message.length;
    } else {
      // Find the next safe comma to split on
      splitIndex = message.indexOf(COMMA, searchStart);
      if (splitIndex === -1) {
        splitIndex = message.length; // No more commas, take the rest
      }
    }

    // 2. Extract the raw chunk (No copy yet, just a view)
    const rawChunk = message.subarray(index, splitIndex);

    // 3. Prepare the final payload buffer
    // We calculate size first to allocate exactly once per chunk
    const startByte = rawChunk[0];
    const endByte = rawChunk[rawChunk.length - 1];

    let prepend = null;
    let append = null;

    // Logic to ensure every chunk is a valid JSON array [...]
    // Case A: Starts with '[' (First chunk), needs ']' at end if not present
    if (startByte === OPEN_BRACKET && endByte !== CLOSE_BRACKET) {
      append = CLOSE_BRACKET;
    } // Case B: Starts with ',' (Middle chunks), needs '[' at start
    else if (startByte === COMMA) {
      prepend = OPEN_BRACKET;

      // If it doesn't end with ']', it needs one
      if (endByte !== CLOSE_BRACKET) {
        append = CLOSE_BRACKET;
      }
      // Note: We skip the leading comma in the raw copy later by offsetting
    }

    // 4. Construct the output buffer
    // Calculate final length: Header (4) + (Prepend?) + Body + (Append?)
    // Note: If startByte was COMMA, we usually want to overwrite it with '[',
    // but your original logic kept the comma data or shifted.
    // Standard approach:
    // If raw starts with comma, we replace comma with '[' or insert '['?
    // Your logic: Replaced [0] if it was comma.

    // Optimized construction based on your logic pattern:
    let bodyLength = rawChunk.length;
    let payloadOffset = 4; // Start after 4-byte header

    // Adjust sizes based on brackets
    const hasPrepend = prepend !== null;
    const hasAppend = append !== null;

    // Special handling for the "Comma Start" case to match your logic:
    // Your logic: x[0] = 91; x[i] = data[i]. Effectively replaces comma with '['
    let sourceOffset = 0;
    if (startByte === COMMA) {
      sourceOffset = 1; // Skip the comma from source
      bodyLength -= 1; // Reduce source len
      // We implicitly assume we prepend '[' in this slot
    }

    const totalLength = 4 + (hasPrepend ? 1 : 0) + bodyLength +
      (hasAppend ? 1 : 0);
    const output = new Uint8Array(totalLength);

    // Write Length Header (Little Endian example)
    const datacurrentMessageLength = totalLength - 4;
    output[0] = (datacurrentMessageLength >> 0) & 0xff;
    output[1] = (datacurrentMessageLength >> 8) & 0xff;
    output[2] = (datacurrentMessageLength >> 16) & 0xff;
    output[3] = (datacurrentMessageLength >> 24) & 0xff;

    // Write Prepend (e.g. '[')
    let cursor = 4;
    if (hasPrepend) {
      output[cursor] = prepend;
      cursor++;
    } else if (startByte === COMMA) {
      // If we didn't flag prepend but stripped comma, likely need bracket
      // Based on your specific logic "x[0] = 91", we treat that as a prepend
      output[cursor] = OPEN_BRACKET;
      cursor++;
    }

    // Write Body (Fast copy)
    // We use .set() which is much faster than a loop
    output.set(rawChunk.subarray(sourceOffset), cursor);
    cursor += bodyLength;

    // Write Append (e.g. ']')
    if (hasAppend) {
      output[cursor] = append;
    }

    // 5. Send immediately
    process.stdout.write(output);
    // Force GC only occasionally if needed (every chunk is often too frequent)

    // Move index for next iteration
    index = splitIndex;
  }
}

async function getMessage() {
  for await (const data of process.stdin) {
    if (
      ab.byteLength === 0 && totalMessageLength === 0 &&
      currentMessageLength === 0
    ) {
      const u8 = new Uint8Array(data);
      totalMessageLength = new DataView(u8.subarray(0, 4).buffer).getUint32(
        0,
        true,
      );
      ab.resize(totalMessageLength);
      const message = u8.subarray(4);
      new Uint8Array(ab).set(message, currentMessageLength);
      currentMessageLength += message.length;
    } else {
      if (currentMessageLength < totalMessageLength) {
        const u8 = new Uint8Array(ab);
        const message = new Uint8Array(data);
        u8.set(message, currentMessageLength);
        currentMessageLength += message.length;
      }
    }
    if (currentMessageLength === totalMessageLength) {
      sendMessage(new Uint8Array(ab));
      /*
    await new Promise((resolve) => {
      process.stdout.once("drain", resolve);
    });
      */
      currentMessageLength = 0;
      totalMessageLength = 0;
      ab.resize(0);
      gc();
    }
  }
}

try {
  (async () => {
    await getMessage();
  })();
} catch (e) {
  process.exit(1);
}

Here’s what the random “code converter” Web site program spit out

#!/usr/bin/env -S python3 -u
# https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging
# https://github.com/mdn/webextensions-examples/pull/157
# Note that running python with the `-u` flag is required on Windows,
# in order to ensure that stdin and stdout are opened in binary, rather
# than text, mode.
# https://www.codeconvert.ai/javascript-to-python-converter?id=53a74277-8ee6-4f73-9c22-cb65296481ca
import sys
import asyncio
import gc
from struct import pack, unpack

ab = bytearray()
totalMessageLength = 0
currentMessageLength = 0

def sendMessage(message: bytearray):
    COMMA = 44
    OPEN_BRACKET = 91
    CLOSE_BRACKET = 93
    CHUNK_SIZE = 1024 * 1024

    if len(message) <= CHUNK_SIZE:
        sys.stdout.buffer.write(pack('<I', len(message)))
        sys.stdout.buffer.write(message)
        sys.stdout.buffer.flush()
        return

    index = 0
    while index < len(message):
        if index + CHUNK_SIZE - 8 >= len(message):
            splitIndex = len(message)
        else:
            try:
                splitIndex = message.index(COMMA, index + CHUNK_SIZE - 8)
            except ValueError:
                splitIndex = len(message)

        rawChunk = message[index:splitIndex]
        startByte = rawChunk[0]
        endByte = rawChunk[-1]
        prepend = None
        append = None

        if startByte == OPEN_BRACKET and endByte != CLOSE_BRACKET:
            append = CLOSE_BRACKET
        elif startByte == COMMA:
            prepend = OPEN_BRACKET
            if endByte != CLOSE_BRACKET:
                append = CLOSE_BRACKET

        bodyLength = len(rawChunk)
        hasPrepend = prepend is not None
        hasAppend = append is not None
        sourceOffset = 0

        if startByte == COMMA:
            sourceOffset = 1
            bodyLength -= 1

        totalLength = 4 + (1 if hasPrepend else 0) + bodyLength + (1 if hasAppend else 0)
        output = bytearray(totalLength)
        datacurrentMessageLength = totalLength - 4

        output[0:4] = pack('<I', datacurrentMessageLength)
        cursor = 4

        if hasPrepend:
            output[cursor] = prepend
            cursor += 1
        elif startByte == COMMA:
            output[cursor] = OPEN_BRACKET
            cursor += 1

        output[cursor:cursor+bodyLength] = rawChunk[sourceOffset:sourceOffset+bodyLength]
        cursor += bodyLength

        if hasAppend:
            output[cursor] = append

        sys.stdout.buffer.write(output)
        sys.stdout.buffer.flush()
        index = splitIndex

async def getMessage():
    global ab, totalMessageLength, currentMessageLength
    loop = asyncio.get_event_loop()
    reader = asyncio.StreamReader()
    protocol = asyncio.StreamReaderProtocol(reader)
    await loop.connect_read_pipe(lambda: protocol, sys.stdin)

    while True:
        data = await reader.read(65536)
        if not data:
            break

        if len(ab) == 0 and totalMessageLength == 0 and currentMessageLength == 0:
            totalMessageLength = unpack('<I', data[:4])[0]
            ab = bytearray(totalMessageLength)
            message = data[4:]
            ab[0:len(message)] = message
            currentMessageLength = len(message)
        else:
            if currentMessageLength < totalMessageLength:
                message = data
                ab[currentMessageLength:currentMessageLength+len(message)] = message
                currentMessageLength += len(message)

        if currentMessageLength == totalMessageLength:
            sendMessage(ab)
            currentMessageLength = 0
            totalMessageLength = 0
            ab = bytearray()
            gc.collect()

try:
    asyncio.run(getMessage())
except Exception:
    sys.exit(1)

Test comparing speed to process and echo back 64 MiB from the browser by native applications, run in DevTools on Chromium Version 145.0.7612.0 (Developer Build) (64-bit). The applications not commented out now support 64 MiB processing. The applications (JavaScript engines, runtimes, WASM compiled by AssemblyScript, executed by wasmtime and/or bun; bun executes the WASM code faster in my tests). nm_wasm is Rust source code compiled to WASM. There’s a WASM version that supports 64 MiB JSON processing produced by Bytecode Alliance’s javy, not in this current test. The applications commented out support 1 MiB processing from browser, so far. The goal is support 64 MiB for all applications listed.

var runtimes = new Map([/*
  ["nm_assemblyscript",0],
  ["nm_bash",0],
  ["nm_bun",0],
  ["nm_c",0],
  ["nm_cpp",0],
  ["nm_d8",0],
  ["nm_deno",0],
  ["nm_llrt",0],
  ["nm_nodejs",0],
      */
["nm_python", 0],
["nm_qjs", 0], ["nm_assemblyscript", 0], ["nm_wasm", 0], ["nm_rust", 0],
["nm_nodejs", 0], ["nm_deno", 0], ["nm_bun", 0]/*
  ["nm_shermes",0],
  ["nm_spidermonkey",0],
  ["nm_tjs",0],
  ["nm_typescript",0],
  ["nm_wasm",0]
  */
]);
for (const [runtime] of runtimes) {
  try {
    const {resolve, reject, promise} = Promise.withResolvers();
    const now = performance.now();
    const input = Array(209715 * 64);
    let len = input.length;
    let bytes = 0;
    const port = chrome.runtime.connectNative(runtime);
    port.onMessage.addListener( (message) => {
      //console.assert(message.length === 209715, {message, runtime});
      bytes += message.length;
      if (bytes === len) {
        runtimes.set(runtime, (performance.now() - now) / 1000);
        port.disconnect();
        resolve();
      }
    }
    );
    port.onDisconnect.addListener( () => reject(chrome.runtime.lastError));
    port.postMessage(input);
    if (runtime === "nm_spidermonkey") {
      port.postMessage("\r\n\r\n");
    }
    await promise;
  } catch (e) {
    console.log(e, runtime);
    continue;
  }
}
var sorted = [...runtimes].sort( ([,a], [,b]) => a < b ? -1 : a === b ? 0 : 1);
console.table(sorted);

Just a few tests and results

0	'nm_wasm'	4.004299999952316
1	'nm_assemblyscript'	4.0318000000715255
2	'nm_rust'	4.044
3	'nm_qjs'	4.371800000071525
4	'nm_deno'	4.890399999976158
5	'nm_nodejs'	5.003
6	'nm_bun'	5.678599999904632
7	'nm_python'	6.6525
0	'nm_rust'	3.466
1	'nm_wasm'	3.617100000023842
2	'nm_assemblyscript'	3.6900999999046324
3	'nm_qjs'	3.891399999976158
4	'nm_nodejs'	4.1157999999523165
5	'nm_deno'	5.472100000023842
6	'nm_python'	5.763799999952316
7	'nm_bun'	5.9561000000238415
0	'nm_wasm'	3.7041000000238418
1	'nm_nodejs'	4.397199999928475
2	'nm_rust'	4.418399999976158
3	'nm_assemblyscript'	4.427900000095367
4	'nm_qjs'	4.445399999976158
5	'nm_deno'	4.781300000071526
6	'nm_python'	5.446399999976158
7	'nm_bun'	6.1075

I asked for human help. I still can use it, with human eyes on the code the online program spit out. It works. Can you improve the code?