Compare commits

...
7 changed files with 199 additions and 3 deletions

No files matched your search

+3
View File
@@ -52,6 +52,9 @@ jobs:
- name: Test
run: npm test
- name: Test terminal background rendering
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
- name: Test terminal keyword highlight performance
env:
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
@@ -24,7 +24,7 @@ test("renderer activity follows the hibernate setting instead of active-tab visi
);
});
test("inactive terminal surfaces remain painted and non-interactive without hibernate", () => {
test("inactive terminal surfaces remain mounted and non-interactive without hibernate", () => {
assert.match(supportSource, /resolveTerminalHibernateEnabledForProtocol\(terminalSettings, host\.protocol\)/);
assert.match(
supportSource,
+3 -2
View File
@@ -227,14 +227,15 @@ test("hidden terminal layers measure once when their layout must stay active", (
}), true);
});
test("inactive terminal pane keeps rendering when hibernate is disabled", () => {
test("inactive terminal pane parks its screen without changing live dimensions", () => {
const inactiveStyle = resolveInactiveTerminalPaneStyle(
{ left: "40px", top: 0, width: "640px", height: "480px" },
{ width: 1180, height: 720 },
false,
);
assert.equal(inactiveStyle.left, "40px");
assert.equal(inactiveStyle.left, 0);
assert.equal(inactiveStyle.transform, "translateX(calc(-100vw - 100%))");
assert.equal(inactiveStyle.top, 0);
assert.equal(inactiveStyle.visibility, "visible");
assert.equal(inactiveStyle.pointerEvents, "none");
+9
View File
@@ -22,6 +22,7 @@ export type TerminalPaneStyle = {
width?: string | number;
height?: string | number;
visibility?: string;
transform?: string;
pointerEvents?: string;
zIndex?: number;
};
@@ -65,6 +66,14 @@ export function resolveInactiveTerminalPaneStyle<T extends TerminalPaneStyle>(
): T {
return {
...layoutStyle,
// Keep the measured box and live parser, but move the screen outside the
// viewport so xterm's IntersectionObserver pauses rendering. Occlusion and
// visibility:hidden alone do not stop xterm's WebGL refreshes.
// Reset cached split offsets before translating: they can exceed the current
// viewport after a window shrinks. Translate by both pane and viewport width,
// including pinned panes wider than a window resized in the background.
left: 0,
transform: "translateX(calc(-100vw - 100%))",
visibility: hibernateHiddenTabs ? "hidden" : "visible",
pointerEvents: "none",
zIndex: 0,
@@ -0,0 +1,40 @@
# Background terminal rendering (#3278)
Inactive panes retain their live terminal and measured dimensions, but move
outside the viewport. This lets xterm's IntersectionObserver stop painting
without stopping output parsing or rebuilding the terminal on every tab switch.
The existing reveal, resize and WebGL recovery paths remain responsible for
restoring the current screen. No new output queue or lossy truncation is added.
## Regression coverage
Run `npm run test:terminal-background-rendering` with a graphical Electron
session (Linux CI uses xvfb). It bundles the production runtime and inactive
pane style helper, creates five real terminals, and verifies:
- The visible terminal paints while all four background terminals do not.
- Output is already parsed in each hidden terminal and dimensions stay valid.
- Twelve hide/reveal cycles repaint newly received output.
- A window-area resize does not collapse the hidden terminal's measured width.
- Cursor-addressed alternate-screen output survives hiding and a reveal resize.
- Two visible split panes paint while the remaining hidden panes do not.
Before this change, the first visibility assertion fails: each background pane
paints 12 frames instead of zero. The test does not claim to reproduce the
reporter's delayed high-CPU condition, and direct writes in this harness do not
exercise the complete SSH/output transport path.
## Local application validation
A separate Netcatty development instance with an isolated profile was tested on
macOS (M2 Max, 32GB), using five real local shell sessions. Four sessions emitted
continuous logs. The UI was exercised through tab switching, split creation,
focus mode, detaching back to a tab, closing the extra split, and window resizing.
Actual screen captures were checked for restored content. CPU comparisons use
that instance's renderer and GPU process metrics, not other applications.
This is a CPU optimization for invisible panes. It does not promise lower
history/GPU memory usage, nor establish the root cause of every #3278 report.
Remote SSH, Windows/Linux interactive recovery and the reporter's M4 environment
still require separate validation; this PR should reference rather than close
that issue automatically.
+1
View File
@@ -65,6 +65,7 @@
"test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:tray-panel-layout": "electron scripts/tray-panel-layout.live.test.cjs",
"test:terminal-background-rendering": "electron scripts/terminal-background-rendering.live.test.cjs",
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
"test:xterm-macos-selection": "electron scripts/xterm-macos-column-selection.live.test.cjs",
"test:xterm-keyword-highlight-performance": "electron scripts/xterm-keyword-highlight-performance.live.test.cjs",
@@ -0,0 +1,142 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
require("node:test")("background terminals stop painting without stopping output", {
skip: "run with Electron to exercise real terminal visibility and rendering",
}, () => {});
} else {
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const root = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("background-rendering")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
let win;
const cleanup = (code) => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
const bundle = esbuild.buildSync({
stdin: {
contents: [
'export {createXTermRuntime} from "./components/terminal/runtime/createXTermRuntime";',
'export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal";',
'export {resolveInactiveTerminalPaneStyle} from "./components/terminalPaneVisibility";',
].join("\n"),
loader: "ts", resolveDir: root,
},
bundle: true, format: "cjs", platform: "browser", target: "chrome148", write: false,
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
void app.whenReady().then(async () => {
win = new BrowserWindow({
show: true, width: 1100, height: 740,
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false },
});
await win.loadURL("data:text/html,<body style='margin:0;background:%23111'><div id='root'></div>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
const result = await win.webContents.executeJavaScript(`(async () => {
const assert = require('node:assert/strict');
const loaded = {exports:{}};
((module,exports)=>{${bundle}})(loaded,loaded.exports);
const {createXTermRuntime,DEFAULT_TERMINAL_SETTINGS,resolveInactiveTerminalPaneStyle} = loaded.exports;
const wait = ms => new Promise(resolve=>setTimeout(resolve,ms));
const ref = current => ({current});
const panes = [];
const root = document.getElementById('root');
root.style.cssText = 'position:relative;width:1050px;height:660px;overflow:hidden';
const visibleStyle = {left:'0px',top:'0px',width:'1050px',height:'660px'};
const apply = (pane, visible, layout = visibleStyle) => {
const style = visible ? layout : resolveInactiveTerminalPaneStyle(layout,{width:1050,height:660},false,true);
pane.el.style.cssText = 'position:absolute;background:#111';
Object.assign(pane.el.style,style);
pane.el.style.zIndex = visible ? '10' : '0';
};
for(let index=0;index<5;index++) {
const el=document.createElement('div');root.appendChild(el);
const pane={el,frames:0};apply(pane,true);
const r=createXTermRuntime({
container:el,host:{id:'test-'+index,label:'test',hostname:'localhost',protocol:'ssh'},
fontFamilyId:'jetbrains-mono',resolvedFontFamily:'monospace',fontSize:14,
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,cursorBlink:false}),
terminalBackend:{write:()=>{},resize:()=>{},openExternalAvailable:false},
sessionRef:ref('test-'+index),hotkeySchemeRef:ref('disabled'),disableTerminalFontZoomRef:ref(false),
keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),isBroadcastEnabledRef:ref(false),
onBroadcastInputRef:ref(undefined),sessionId:'test-'+index,statusRef:ref('connected'),
commandBufferRef:ref(''),requestSearchFocus:()=>{},
});
r.fitAddon.fit();r.term.onRender(()=>pane.frames++);
pane.r=r;panes.push(pane);
await new Promise(resolve=>r.term.write('ready\\r\\n',resolve));
}
assert.ok(root.querySelector('canvas'),'exercise WebGL, not only a fake renderer');
const write=(pane,data)=>new Promise(resolve=>pane.r.term.write(data,resolve));
const size=pane=>[pane.r.term.cols,pane.r.term.rows];
const beforeSizes=panes.map(size);
for(let i=1;i<5;i++)apply(panes[i],false);
await wait(200);
const before=panes.map(p=>p.frames);
for(let tick=0;tick<12;tick++) {
await Promise.all(panes.map(p=>write(p,'background-'+tick+'\\r\\n')));
await wait(30);
}
const hiddenFrames=panes.map((p,i)=>p.frames-before[i]);
assert.ok(hiddenFrames[0]>0,'visible terminal still paints');
assert.deepEqual(hiddenFrames.slice(1),[0,0,0,0],'background terminals must not paint');
assert.deepEqual(panes.map(size),beforeSizes,'hiding preserves terminal dimensions');
for(const pane of panes) {
const b=pane.r.term.buffer.active;
assert.equal(b.getLine(b.baseY+b.cursorY-1).translateToString(true),'background-11','hidden output is already parsed');
}
// Cached right-hand split offsets can exceed a restored window's width.
apply(panes[4],false,{...visibleStyle,left:'1600px'});
await wait(150);
const offsetFrames=panes[4].frames;
await write(panes[4],'cached-offset\\r\\n');await wait(100);
assert.equal(panes[4].frames,offsetFrames,'cached split offsets must not leave hidden panes painting');
assert.ok(panes[4].el.getBoundingClientRect().right<=0,'park the entire cached split pane');
assert.deepEqual(size(panes[4]),beforeSizes[4]);
// No zero-width fitting during a window resize while tabs are parked.
root.style.width='850px';
assert.equal(panes[1].el.clientWidth,1050);
for(let tick=0;tick<12;tick++) {
const pane=panes[1+tick%4];
await write(pane,'switch-'+tick+'\\r\\n');
const start=pane.frames;apply(pane,true);await wait(100);
assert.ok(pane.frames>start,'reveal must repaint');
assert.deepEqual(size(pane),beforeSizes[1]);apply(pane,false);await wait(30);
}
// A full-screen program must keep interpreting cursor operations while hidden.
const tui=panes[2];
await write(tui,'\\x1b[?1049h\\x1b[2J\\x1b[HOLD');
await wait(100);const tuiBefore=tui.frames;
await write(tui,'\\x1b[HNEW\\x1b[2;1Hsecond row');await wait(100);
assert.equal(tui.frames,tuiBefore);
assert.equal(tui.r.term.buffer.active.getLine(0).translateToString(true),'NEW');
apply(tui,true,{...visibleStyle,width:'850px'});tui.r.fitAddon.fit();await wait(100);
assert.ok(tui.frames>tuiBefore);
assert.equal(tui.r.term.buffer.active.getLine(0).translateToString(true),'NEW');
await write(tui,'\\x1b[?1049l');
// Two visible split panes paint; parked panes continue parsing without painting.
for(const pane of panes)apply(pane,false);
apply(panes[0],true,{left:'0px',top:'0px',width:'425px',height:'660px'});
apply(panes[1],true,{left:'425px',top:'0px',width:'425px',height:'660px'});
panes[0].r.fitAddon.fit();panes[1].r.fitAddon.fit();await wait(150);
const splitBefore=panes.map(p=>p.frames);
await Promise.all(panes.map(p=>write(p,'split-final\\r\\n')));await wait(100);
assert.ok(panes[0].frames>splitBefore[0] && panes[1].frames>splitBefore[1]);
assert.deepEqual(panes.slice(2).map((p,i)=>p.frames-splitBefore[i+2]),[0,0,0]);
const summary={hiddenFrames,rapidSwitches:12,alternateScreen:true,split:true,sizes:beforeSizes};
panes.forEach(p=>p.r.dispose());root.replaceChildren();
assert.equal(document.querySelectorAll('.xterm').length,0);
return summary;
})()`);
console.log("TERMINAL_BACKGROUND_RENDERING_OK", JSON.stringify(result));
cleanup(0);
}).catch(error=>{console.error(error);cleanup(1);});
}