noVNC/tests/perf/rfb_stream_bench.js

318 lines
9.0 KiB
JavaScript

const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
const search = new URLSearchParams(window.location.search);
const scenario = search.get('scenario') || 'protocol-copyrect';
const repoUrl = search.get('repo');
window.__BENCH_DONE = false;
window.__RESULT = null;
window.__ERROR = null;
function setStatus(text) {
statusEl.textContent = text;
document.title = text;
}
function setResult(result) {
window.__RESULT = result;
window.__BENCH_DONE = true;
setStatus('done');
resultEl.textContent = JSON.stringify(result, null, 2);
}
function setError(error) {
const rendered = error instanceof Error ? `${error.message}\n${error.stack || ''}` : String(error);
window.__ERROR = rendered;
window.__BENCH_DONE = true;
setStatus('error');
resultEl.textContent = rendered;
console.error(error);
}
function parseInteger(name, fallback) {
const raw = search.get(name);
if (raw === null || raw === '') {
return fallback;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) {
throw new Error(`Invalid integer for "${name}": ${raw}`);
}
return value;
}
function ensureRepoUrl(rawRepoUrl) {
if (!rawRepoUrl) {
throw new Error('Missing "repo" query parameter');
}
const normalized = rawRepoUrl.endsWith('/') ? rawRepoUrl : `${rawRepoUrl}/`;
return new URL(normalized);
}
function receive(ws, bytes) {
ws._receiveData(bytes);
}
function dispatchMessage(ws, data) {
ws.onmessage(new MessageEvent('message', { data }));
}
function createVersionBuffer() {
return new TextEncoder().encode('RFB 003.008\n');
}
function encodeName(name) {
return new TextEncoder().encode(name);
}
function buildServerInit(width, height, name) {
const nameBytes = encodeName(name);
const header = new Uint8Array(24);
const view = new DataView(header.buffer);
view.setUint16(0, width, false);
view.setUint16(2, height, false);
header[4] = 24;
header[5] = 24;
header[6] = 0;
header[7] = 1;
view.setUint16(8, 255, false);
view.setUint16(10, 255, false);
view.setUint16(12, 255, false);
header[14] = 16;
header[15] = 8;
header[16] = 0;
view.setUint32(20, nameBytes.length, false);
return { header, nameBytes };
}
function handshake(ws, width, height, name) {
receive(ws, createVersionBuffer());
receive(ws, new Uint8Array([1, 1]));
receive(ws, new Uint8Array([0, 0, 0, 0]));
const init = buildServerInit(width, height, name);
receive(ws, init.header);
receive(ws, init.nameBytes);
}
async function loadModules(repoBase) {
// util/browser.js has a top-level WebCodecs probe that can stall in
// headless Chrome. Remove the APIs before importing RFB so the module
// resolves quickly and deterministically in benchmark runs.
try { delete window.VideoDecoder; } catch {}
try { delete window.EncodedVideoChunk; } catch {}
const [rfbModule, websocketModule] = await Promise.all([
import(new URL('core/rfb.js', repoBase).href),
import(new URL('tests/fake.websocket.js', repoBase).href),
]);
return {
RFB: rfbModule.default,
FakeWebSocket: websocketModule.default,
};
}
function createHarness(RFB, FakeWebSocket) {
const mount = document.createElement('div');
document.body.appendChild(mount);
const ws = new FakeWebSocket();
const rfb = new RFB(mount, ws);
ws._open();
return { mount, rfb, ws };
}
function destroyHarness(harness) {
try {
harness.rfb.disconnect();
} catch {}
if (harness.mount.parentNode !== null) {
harness.mount.parentNode.removeChild(harness.mount);
}
}
function buildCopyRectBatch(rectsPerMessage) {
const bytesPerRect = 16;
const buffer = new ArrayBuffer(4 + (rectsPerMessage * bytesPerRect));
const view = new DataView(buffer);
let offset = 0;
view.setUint8(offset++, 0);
view.setUint8(offset++, 0);
view.setUint16(offset, rectsPerMessage, false);
offset += 2;
for (let i = 0; i < rectsPerMessage; i++) {
view.setUint16(offset, 0, false); offset += 2;
view.setUint16(offset, 0, false); offset += 2;
view.setUint16(offset, 1, false); offset += 2;
view.setUint16(offset, 1, false); offset += 2;
view.setInt32(offset, 1, false); offset += 4; // CopyRect encoding
view.setUint16(offset, 0, false); offset += 2;
view.setUint16(offset, 0, false); offset += 2;
}
return buffer;
}
function createCopyRectMessages(messageCount, rectsPerMessage) {
const messages = [];
for (let i = 0; i < messageCount; i++) {
messages.push(buildCopyRectBatch(rectsPerMessage));
}
return messages;
}
function average(values) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
async function runSmokeRaw(RFB, FakeWebSocket) {
const start = performance.now();
const harness = createHarness(RFB, FakeWebSocket);
try {
handshake(harness.ws, 2, 2, 'smoke');
const update = new Uint8Array(4 + 12 + 4);
const view = new DataView(update.buffer);
let offset = 0;
update[offset++] = 0;
update[offset++] = 0;
view.setUint16(offset, 1, false); offset += 2;
view.setUint16(offset, 0, false); offset += 2;
view.setUint16(offset, 0, false); offset += 2;
view.setUint16(offset, 1, false); offset += 2;
view.setUint16(offset, 1, false); offset += 2;
view.setInt32(offset, 0, false); offset += 4; // Raw encoding
update[offset++] = 0xff;
update[offset++] = 0x00;
update[offset++] = 0x00;
update[offset++] = 0x00;
receive(harness.ws, update);
await new Promise((resolve) => window.setTimeout(resolve, 50));
const pixel = Array.from(harness.rfb.getImageData().data.slice(0, 4));
const expectedPixel = [255, 0, 0, 255];
const pass = harness.rfb._rfbConnectionState === 'connected' &&
pixel.length === expectedPixel.length &&
pixel.every((value, index) => value === expectedPixel[index]);
return {
scenario: 'smoke-raw',
pass,
state: harness.rfb._rfbConnectionState,
pixel,
expectedPixel,
durationMs: performance.now() - start,
};
} finally {
destroyHarness(harness);
}
}
async function runProtocolCopyRect(RFB, FakeWebSocket) {
const iterations = parseInteger('iterations', 6);
const warmup = parseInteger('warmup', 2);
const messages = parseInteger('messages', 50);
const rects = parseInteger('rects', 5000);
const stubDisplay = search.get('stubDisplay') !== '0';
const harness = createHarness(RFB, FakeWebSocket);
try {
handshake(harness.ws, 4, 4, 'bench');
harness.rfb._enabledContinuousUpdates = true;
if (typeof harness.ws._getSentData === 'function') {
harness.ws._getSentData();
}
if (stubDisplay) {
harness.rfb._display.copyImage = () => {};
harness.rfb._display.fillRect = () => {};
harness.rfb._display.blitImage = () => {};
harness.rfb._display.flip = () => {};
}
const payloads = createCopyRectMessages(messages, rects);
const feed = () => {
for (const payload of payloads) {
dispatchMessage(harness.ws, payload);
}
};
for (let i = 0; i < warmup; i++) {
feed();
}
const samples = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
feed();
if (typeof harness.rfb._display.flush === 'function' && harness.rfb._display.pending()) {
await harness.rfb._display.flush();
}
samples.push(performance.now() - start);
}
const avg = average(samples);
const min = Math.min(...samples);
const max = Math.max(...samples);
const rectsPerIteration = messages * rects;
return {
scenario: 'protocol-copyrect',
stubDisplay,
iterations,
warmup,
messages,
rectsPerMessage: rects,
rectsPerIteration,
avgMs: avg,
minMs: min,
maxMs: max,
rectsPerSecond: rectsPerIteration / (avg / 1000),
samplesMs: samples,
};
} finally {
destroyHarness(harness);
}
}
async function run() {
const repoBase = ensureRepoUrl(repoUrl);
setStatus(`loading ${scenario}`);
const { RFB, FakeWebSocket } = await loadModules(repoBase);
if (scenario === 'smoke-raw') {
setStatus('running smoke-raw');
setResult(await runSmokeRaw(RFB, FakeWebSocket));
return;
}
if (scenario === 'protocol-copyrect') {
setStatus('running protocol-copyrect');
setResult(await runProtocolCopyRect(RFB, FakeWebSocket));
return;
}
throw new Error(`Unknown scenario "${scenario}"`);
}
run().catch(setError);