Files
NetMesh/scripts/xterm-webgl-atlas-overflow.live.test.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

267 lines
11 KiB
JavaScript

"use strict";
if (!process.versions.electron) {
const test = require("node:test");
test("xterm WebGL atlas stays within renderer texture capacity", {
skip: "run with Electron so WebGL is available",
}, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const electron = require("electron");
const appRoot = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-xterm-webgl-overflow-"));
electron.app.setPath("userData", userData);
electron.app.commandLine.appendSwitch("use-angle", "swiftshader");
electron.app.commandLine.appendSwitch("enable-unsafe-swiftshader");
electron.app.on("window-all-closed", () => {});
const cleanup = (exitCode) => {
fs.rmSync(userData, { recursive: true, force: true });
electron.app.exit(exitCode);
};
void electron.app.whenReady().then(async () => {
const window = new electron.BrowserWindow({
show: false,
width: 900,
height: 560,
paintWhenInitiallyHidden: true,
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body,#terminal{width:800px;height:480px;margin:0}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const webglPath = require.resolve("@xterm/addon-webgl", { paths: [appRoot] });
const result = await window.webContents.executeJavaScript(`(async () => {
const { Terminal } = require(${JSON.stringify(xtermPath)});
const { WebglAddon } = require(${JSON.stringify(webglPath)});
const container = document.getElementById("terminal");
const errors = [];
window.addEventListener("error", event => {
errors.push(String(event.error?.stack || event.message || event.error));
event.preventDefault();
});
window.addEventListener("unhandledrejection", event => {
errors.push(String(event.reason?.stack || event.reason));
event.preventDefault();
});
const bootstrap = new Terminal({ cols: 80, rows: 24, allowProposedApi: true });
bootstrap.open(container);
const bootstrapAddon = new WebglAddon({ preserveDrawingBuffer: true });
bootstrap.loadAddon(bootstrapAddon);
await new Promise(resolve => setTimeout(resolve, 50));
const bootstrapAtlas = bootstrap._core?._renderService?._renderer?.value?._charAtlas;
if (!bootstrapAtlas) throw new Error("WebGL texture atlas was not created");
bootstrapAtlas.constructor.maxAtlasPages = 4;
bootstrapAtlas.constructor.maxTextureSize = 512;
bootstrap.dispose();
container.replaceChildren();
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true });
term.open(container);
const addon = new WebglAddon({ preserveDrawingBuffer: true });
let removals = 0;
addon.onRemoveTextureAtlasCanvas(() => { removals += 1; });
term.loadAddon(addon);
await new Promise(resolve => setTimeout(resolve, 50));
const renderer = term._core?._renderService?._renderer?.value;
const atlas = renderer?._charAtlas;
const glyphRenderer = renderer?._glyphRenderer?.value;
if (!atlas || !glyphRenderer || renderer !== addon._renderer) {
throw new Error("WebGL renderer internals are unavailable");
}
const captureCellSignatures = columns => {
const gl = renderer._gl;
const canvas = renderer._canvas;
const cell = renderer.dimensions?.device?.cell;
if (!gl || !canvas || !cell?.width || !cell?.height) {
throw new Error("WebGL canvas dimensions are unavailable");
}
const pixels = new Uint8Array(canvas.width * canvas.height * 4);
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const grid = 4;
return columns.map(column => {
const sums = new Array(grid * grid * 4).fill(0);
const counts = new Array(grid * grid).fill(0);
const startX = Math.max(0, Math.floor(column * cell.width));
const endX = Math.min(canvas.width, Math.ceil((column + 1) * cell.width));
const startY = 0;
const endY = Math.min(canvas.height, Math.ceil(cell.height));
for (let y = startY; y < endY; y += 1) {
const gridY = Math.min(grid - 1, Math.floor((y / cell.height) * grid));
const sourceY = canvas.height - y - 1;
for (let x = startX; x < endX; x += 1) {
const gridX = Math.min(
grid - 1,
Math.floor(((x - column * cell.width) / cell.width) * grid),
);
const bucket = gridY * grid + gridX;
const pixel = (sourceY * canvas.width + x) * 4;
for (let channel = 0; channel < 4; channel += 1) {
sums[bucket * 4 + channel] += pixels[pixel + channel];
}
counts[bucket] += 1;
}
}
return sums.map((sum, index) => {
const count = counts[Math.floor(index / 4)];
return count > 0 ? sum / count : 0;
});
});
};
const signatureDiff = (left, right) => {
let difference = 0;
for (let index = 0; index < left.length; index += 1) {
difference = Math.max(difference, Math.abs(left[index] - right[index]));
}
return difference;
};
const maxSignatureDiff = (left, right) => Math.max(
...left.map((signature, index) => signatureDiff(signature, right[index])),
);
const writeAndWaitForRender = (data, label) => new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
disposable.dispose();
reject(new Error("timed out waiting for terminal render: " + label));
}, 10000);
const disposable = term.onRender(() => {
clearTimeout(timeout);
disposable.dispose();
requestAnimationFrame(() => requestAnimationFrame(resolve));
});
term.write(data);
});
const marker = "AFTER_EVICTION_0123456789";
const markerColumns = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
await writeAndWaitForRender(
"\\x1b[H\\x1b[2J\\x1b[97m" + marker + "\\x1b[0m",
"initial marker",
);
const markerReference = captureCellSignatures(markerColumns);
const generateUniqueGlyphFlood = (count, offset) => {
const base = 0x4E00;
const range = 0x9FFF - base;
const perRow = 40;
let output = "";
for (let index = 0; index < count; index += 1) {
output += String.fromCodePoint(base + ((offset + index) % range));
if ((index + 1) % perRow === 0 && index + 1 < count) output += "\\r\\n";
}
return output;
};
const glyphsPerChunk = 23 * 40 - 1;
let peakPages = atlas.pages.length;
for (let chunk = 0; chunk < 32; chunk += 1) {
await writeAndWaitForRender(
"\\x1b[H\\x1b[2J" + generateUniqueGlyphFlood(glyphsPerChunk, chunk * glyphsPerChunk),
"normal atlas flood " + chunk,
);
peakPages = Math.max(peakPages, atlas.pages.length);
if (errors.length > 0 || atlas.pages.length > glyphRenderer._atlasTextures.length) break;
}
await writeAndWaitForRender(
"\\x1b[H\\x1b[2J\\x1b[97m" + marker + "\\x1b[0m",
"post-eviction marker",
);
const markerAfterEviction = captureCellSignatures(markerColumns);
const markerPixelDiff = maxSignatureDiff(markerReference, markerAfterEviction);
const normalPages = atlas.pages.length;
const normalRemovals = removals;
await writeAndWaitForRender("\\x1b[H\\x1b[2J", "wide-glyph blank reference");
const wideColumns = [0, 7, 15, 23, 31];
const blankSignatures = captureCellSignatures(wideColumns);
const cellWidth = renderer.dimensions.device.cell.width;
const joinedLength = Math.min(term.cols - 1, Math.ceil(atlas._textureSize / cellWidth) + 32);
if (joinedLength * cellWidth <= atlas._textureSize) {
throw new Error("joined glyph is not wider than a normal atlas page");
}
const wideMarker = "W".repeat(joinedLength);
term.registerCharacterJoiner(text => text.startsWith(wideMarker) ? [[0, joinedLength]] : []);
await writeAndWaitForRender("\\x1b[H" + wideMarker, "oversized glyph");
const wideSignatures = captureCellSignatures(wideColumns);
const minimumWidePixelDiff = Math.min(
...wideSignatures.map(
(signature, index) => signatureDiff(signature, blankSignatures[index]),
),
);
const state = {
errors,
pages: normalPages,
peakPages,
textures: glyphRenderer._atlasTextures.length,
removals,
normalRemovals,
markerPixelDiff,
oversizedPages: atlas.pages.length,
oversizedPageCreated: !!atlas._overflowSizePage,
oversizedRemovals: removals - normalRemovals,
minimumWidePixelDiff,
};
term.dispose();
return state;
})()`);
assert.equal(result.textures, 4, `expected a deterministic 4-texture test cap: ${JSON.stringify(result)}`);
assert.equal(result.errors.length, 0, `WebGL rendering threw after atlas growth: ${result.errors[0] || ""}`);
assert.ok(
result.peakPages <= result.textures,
`atlas grew beyond renderer texture capacity: ${JSON.stringify(result)}`,
);
assert.ok(
result.normalRemovals > 0,
`normal atlas pages never exercised capacity recovery: ${JSON.stringify(result)}`,
);
assert.ok(
result.markerPixelDiff <= 14,
`rendered text changed after atlas eviction: ${JSON.stringify(result)}`,
);
assert.equal(
result.pages,
result.textures,
`normal atlas pages did not reach texture capacity: ${JSON.stringify(result)}`,
);
assert.ok(
result.oversizedPageCreated,
`oversized glyph did not use its dedicated atlas page: ${JSON.stringify(result)}`,
);
assert.ok(
result.oversizedPages <= result.textures,
`oversized glyph exceeded texture capacity: ${JSON.stringify(result)}`,
);
assert.ok(
result.oversizedRemovals > 0,
`oversized glyph did not evict full atlas pages: ${JSON.stringify(result)}`,
);
assert.ok(
result.minimumWidePixelDiff > 14,
`every sampled part of the oversized glyph must render visible pixels: ${JSON.stringify(result)}`,
);
process.stdout.write(`XTERM_WEBGL_ATLAS_OVERFLOW_OK ${JSON.stringify(result)}\n`);
window.destroy();
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}