From 01fb1cb3cffcb561addecb9863bf319eb48757d8 Mon Sep 17 00:00:00 2001 From: s-celles Date: Sat, 25 Jul 2026 09:06:34 +0200 Subject: [PATCH 1/6] build(terminal): render DEC 2026 frames at full rate via an xterm patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/patch-xterm-sync-render.cjs (run from postinstall, after the existing webgl atlas patch) to render a synchronized-output frame the moment it closes instead of on the next debounced tick. xterm buffers rows while DEC 2026 synchronized output is on and, on close, schedules the paint through the render debouncer. Under a continuous full-screen animation the next frame opens a new 2026 block before that rAF fires, and `_renderRows` skips while sync is on, so the paint is dropped and the frame only appears on the 1000ms sync timeout — pinning the display at ~1fps. The patch renders synchronously when a sync buffer was just flushed, so a completed frame paints before the next can reopen the mode. Measured against a 30fps animated-background TUI: ~1fps to the frame arrival rate, coherent (no partial-frame tearing). Idempotent and marker-guarded, like patch-xterm-webgl-atlas.cjs; a version bump that moves the minified target fails the install rather than silently losing the fix. Upstreamable to xterm.js. Assisted by AI. --- package.json | 2 +- scripts/patch-xterm-sync-render.cjs | 88 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 scripts/patch-xterm-sync-render.cjs diff --git a/package.json b/package.json index 90a6450843..49858887cf 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never", "pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never", "pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never", - "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs", + "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs && node scripts/patch-xterm-sync-render.cjs", "rebuild": "electron-builder install-app-deps", "tool:cli": "node electron/cli/netcatty-tool-cli.cjs", "generate:capability-tools": "node scripts/generate-capability-tools.cjs", diff --git a/scripts/patch-xterm-sync-render.cjs b/scripts/patch-xterm-sync-render.cjs new file mode 100644 index 0000000000..98eecaa2ee --- /dev/null +++ b/scripts/patch-xterm-sync-render.cjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/* global process, console */ +/** + * Render a DEC 2026 synchronized-output frame the moment it closes, instead of + * on the next debounced tick. + * + * xterm's RenderService buffers rows while synchronized output is on and, on + * close, requests a refresh that is scheduled through the render debouncer + * (requestAnimationFrame). Under a continuous full-screen animation the next + * frame opens a new 2026 block before that rAF fires, and `_renderRows` skips + * while sync is on — so the debounced paint is dropped and the frame only + * appears when the 1000ms synchronized-output timeout expires. The display is + * then pinned at ~1fps however fast frames arrive. + * + * The fix renders synchronously when a synchronized-output buffer was just + * flushed. `refreshRows(...,sync,...)` normally does + * `sync ? _renderRows(...) : _renderDebouncer.refresh(...)`; we widen the + * condition to also render synchronously when the flush returned buffered rows + * (the local holding `_syncOutputHandler.flush()`). At that point the mode is + * already off, so the completed frame paints before the next can reopen it. + * + * Upstream: https://github.com/xtermjs/xterm.js (fix pending). Applied here as a + * string patch on the minified build, like patch-xterm-webgl-atlas.cjs, so a + * version bump that moves the target surfaces as an install failure rather than + * silently losing the fix. + * + * Idempotent. + */ +"use strict"; +const fs = require("node:fs"); +const path = require("node:path"); + +const MARKER = "/*netcatty:sync-render*/"; + +// The minified `sync ? _renderRows(a,b) : _renderDebouncer.refresh(a,b,c)` +// ternary, and the `buffered` local to widen it with. Token names differ +// between the CJS and ESM builds, so each target names its own. +const TARGETS = [ + { + file: "node_modules/@xterm/xterm/lib/xterm.js", + // const r = flush(); ... i ? _renderRows(e,t) : _renderDebouncer.refresh(e,t,this._rowCount) + from: "i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + to: "(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + }, + { + file: "node_modules/@xterm/xterm/lib/xterm.mjs", + // let o = flush(); ... r ? _renderRows(e,t) : _renderDebouncer.refresh(e,t,this._rowCount) + from: "r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + to: "(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + }, +]; + +let patched = 0; +let already = 0; +let missing = 0; + +for (const { file, from, to } of TARGETS) { + const abs = path.resolve(process.cwd(), file); + let src; + try { + src = fs.readFileSync(abs, "utf8"); + } catch { + console.warn(`[patch-xterm-sync-render] skip (not found): ${file}`); + missing++; + continue; + } + const withMarker = to + MARKER; + if (src.includes(withMarker)) { + already++; + continue; + } + if (src.split(from).length - 1 === 1) { + fs.writeFileSync(abs, src.replace(from, withMarker), "utf8"); + patched++; + } else { + console.warn( + `[patch-xterm-sync-render] ERROR: sync-render ternary not found (or ambiguous) in ${file}. ` + + "Refresh the minified target before upgrading @xterm/xterm.", + ); + missing++; + } +} + +console.log( + `[patch-xterm-sync-render] patched=${patched} already=${already} missing=${missing}`, +); + +if (missing > 0) process.exitCode = 1; From 752f80ba3728f083b187980eedb177e4a3645b8c Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:59:33 +0800 Subject: [PATCH 2/6] test(terminal): cover synchronized frame rendering --- package.json | 3 +- scripts/patch-xterm-sync-render.cjs | 14 +++- scripts/patch-xterm-sync-render.test.cjs | 87 +++++++++++++++++++++++ scripts/xterm-sync-render.live.test.cjs | 89 ++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 scripts/patch-xterm-sync-render.test.cjs create mode 100644 scripts/xterm-sync-render.live.test.cjs diff --git a/package.json b/package.json index 49858887cf..c9f2759071 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,8 @@ "bench:sync-crdt": "tsx scripts/bench-sync-crdt.ts", "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:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs" + "test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs", + "test:xterm-sync-render": "electron scripts/xterm-sync-render.live.test.cjs" }, "dependencies": { "@eslint-community/regexpp": "4.12.2", diff --git a/scripts/patch-xterm-sync-render.cjs b/scripts/patch-xterm-sync-render.cjs index 98eecaa2ee..96ed62325c 100644 --- a/scripts/patch-xterm-sync-render.cjs +++ b/scripts/patch-xterm-sync-render.cjs @@ -52,6 +52,7 @@ const TARGETS = [ let patched = 0; let already = 0; +let upstream = 0; let missing = 0; for (const { file, from, to } of TARGETS) { @@ -65,13 +66,20 @@ for (const { file, from, to } of TARGETS) { continue; } const withMarker = to + MARKER; - if (src.includes(withMarker)) { + const markerMatches = src.split(MARKER).length - 1; + const targetMatches = src.split(from).length - 1; + const upstreamMatches = src.split(to).length - 1; + if (markerMatches === 1 && src.includes(withMarker)) { already++; continue; } - if (src.split(from).length - 1 === 1) { + if (markerMatches === 0 && targetMatches === 1 && upstreamMatches === 0) { fs.writeFileSync(abs, src.replace(from, withMarker), "utf8"); patched++; + } else if (markerMatches === 0 && targetMatches === 0 && upstreamMatches === 1) { + // The exact upstream fixed form is already present without Netcatty's + // marker. Leave it untouched so an xterm upgrade can retire this patch. + upstream++; } else { console.warn( `[patch-xterm-sync-render] ERROR: sync-render ternary not found (or ambiguous) in ${file}. ` + @@ -82,7 +90,7 @@ for (const { file, from, to } of TARGETS) { } console.log( - `[patch-xterm-sync-render] patched=${patched} already=${already} missing=${missing}`, + `[patch-xterm-sync-render] patched=${patched} already=${already} upstream=${upstream} missing=${missing}`, ); if (missing > 0) process.exitCode = 1; diff --git a/scripts/patch-xterm-sync-render.test.cjs b/scripts/patch-xterm-sync-render.test.cjs new file mode 100644 index 0000000000..49e776eaab --- /dev/null +++ b/scripts/patch-xterm-sync-render.test.cjs @@ -0,0 +1,87 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); +const script = path.resolve(__dirname, "patch-xterm-sync-render.cjs"); +const marker = "/*netcatty:sync-render*/"; +const targets = [ + { + file: "node_modules/@xterm/xterm/lib/xterm.js", + from: "i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + to: "(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + }, + { + file: "node_modules/@xterm/xterm/lib/xterm.mjs", + from: "r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + to: "(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + }, +]; + +const makeTmp = (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-xterm-sync-patch-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +}; + +const writeBuild = (root, target, source = target.from) => { + const file = path.join(root, target.file); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `prefix ${source} suffix`); +}; + +test("patches both xterm builds and is idempotent", async (t) => { + const root = makeTmp(t); + for (const target of targets) writeBuild(root, target); + + const first = await execFileAsync(process.execPath, [script], { cwd: root }); + assert.match(first.stdout, /patched=2 already=0 upstream=0 missing=0/); + assert.equal(first.stderr, ""); + for (const target of targets) { + const source = fs.readFileSync(path.join(root, target.file), "utf8"); + assert.equal(source.includes(target.from), false); + assert.equal(source.includes(`${target.to}${marker}`), true); + } + + const afterFirstRun = targets.map((target) => + fs.readFileSync(path.join(root, target.file), "utf8") + ); + const second = await execFileAsync(process.execPath, [script], { cwd: root }); + assert.match(second.stdout, /patched=0 already=2 upstream=0 missing=0/); + assert.deepEqual( + targets.map((target) => fs.readFileSync(path.join(root, target.file), "utf8")), + afterFirstRun, + ); +}); + +test("leaves the exact upstream fix untouched", async (t) => { + const root = makeTmp(t); + for (const target of targets) writeBuild(root, target, target.to); + + const result = await execFileAsync(process.execPath, [script], { cwd: root }); + assert.match(result.stdout, /patched=0 already=0 upstream=2 missing=0/); + for (const target of targets) { + const source = fs.readFileSync(path.join(root, target.file), "utf8"); + assert.equal(source.includes(target.to), true); + assert.equal(source.includes(marker), false); + } +}); + +test("fails closed when a target is missing or ambiguous", async (t) => { + const root = makeTmp(t); + writeBuild(root, targets[0], `${targets[0].from} ${targets[0].from}`); + writeBuild(root, targets[1], "unknown xterm build"); + + await assert.rejects(execFileAsync(process.execPath, [script], { cwd: root }), (error) => { + assert.equal(error.code, 1); + assert.match(error.stdout, /patched=0 already=0 upstream=0 missing=2/); + assert.match(error.stderr, /sync-render ternary not found \(or ambiguous\)/); + return true; + }); +}); diff --git a/scripts/xterm-sync-render.live.test.cjs b/scripts/xterm-sync-render.live.test.cjs new file mode 100644 index 0000000000..f089232bf2 --- /dev/null +++ b/scripts/xterm-sync-render.live.test.cjs @@ -0,0 +1,89 @@ +"use strict"; + +if (!process.versions.electron) { + const test = require("node:test"); + test("closed synchronized-output frames render before the next frame opens", { + skip: "run with Electron so xterm's renderer 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-sync-render-")); + electron.app.setPath("userData", userData); + electron.app.commandLine.appendSwitch("disable-gpu"); + 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: 640, + height: 360, + paintWhenInitiallyHidden: true, + webPreferences: { + contextIsolation: false, + nodeIntegration: true, + sandbox: false, + }, + }); + await window.loadURL( + "data:text/html;charset=utf-8," + encodeURIComponent( + "
", + ), + ); + + const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] }); + const result = await window.webContents.executeJavaScript(`(async () => { + const { Terminal } = require(${JSON.stringify(xtermPath)}); + const term = new Terminal({ cols: 10, rows: 2, cursorBlink: false }); + term.open(document.getElementById("terminal")); + + const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)); + await nextFrame(); + await nextFrame(); + + const renders = []; + const renderSubscription = term.onRender(event => renders.push(event)); + const write = data => new Promise(resolve => term.write(data, resolve)); + const firstFrameStart = "\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA"; + const firstFrameClose = "\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l"; + const secondFrameOpen = "\\x1b[?2026h\\x1b[1;1HCC"; + + // Keep the synchronized frame open across two input chunks so xterm + // buffers dirty rows, then queue the next frame before the completed + // first frame's debounced paint can run. + await write(firstFrameStart); + await write(firstFrameClose); + await write(secondFrameOpen); + await nextFrame(); + await nextFrame(); + const rendersBeforeSecondFrameClose = renders.length; + + await write("\\x1b[?2026l"); + await nextFrame(); + renderSubscription.dispose(); + term.dispose(); + return { rendersBeforeSecondFrameClose }; + })()`); + + assert.ok( + result.rendersBeforeSecondFrameClose > 0, + `the completed first frame was not rendered before the second frame opened: ${JSON.stringify(result)}`, + ); + process.stdout.write(`XTERM_SYNC_RENDER_OK ${JSON.stringify(result)}\n`); + window.destroy(); + cleanup(0); + }).catch((error) => { + console.error(error); + cleanup(1); + }); +} From 55ef19d48321d8e38ea30fb86589c6b6f194b925 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:10:50 +0800 Subject: [PATCH 3/6] test(terminal): harden synchronized render coverage --- .github/workflows/test.yml | 5 +- package.json | 2 +- scripts/patch-xterm-sync-render.cjs | 111 +++++++++++------ scripts/patch-xterm-sync-render.test.cjs | 44 +++++-- scripts/xterm-sync-render.live.test.cjs | 146 ++++++++++++++--------- 5 files changed, 207 insertions(+), 101 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00e11d955d..0d3454b567 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: - name: Install shell test dependencies run: | sudo apt-get update - sudo apt-get install -y fish + sudo apt-get install -y fish xvfb - name: Install deps run: npm ci @@ -49,5 +49,8 @@ jobs: - name: Test run: npm test + - name: Test synchronized terminal rendering + run: xvfb-run -a npm run test:xterm-sync-render + - name: Build run: npm run build diff --git a/package.json b/package.json index c9f2759071..66509eceae 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,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:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs", - "test:xterm-sync-render": "electron scripts/xterm-sync-render.live.test.cjs" + "test:xterm-sync-render": "cross-env NETCATTY_XTERM_SYNC_RENDER_LIVE=1 node scripts/xterm-sync-render.live.test.cjs" }, "dependencies": { "@eslint-community/regexpp": "4.12.2", diff --git a/scripts/patch-xterm-sync-render.cjs b/scripts/patch-xterm-sync-render.cjs index 96ed62325c..06b53c7d87 100644 --- a/scripts/patch-xterm-sync-render.cjs +++ b/scripts/patch-xterm-sync-render.cjs @@ -20,72 +20,115 @@ * already off, so the completed frame paints before the next can reopen it. * * Upstream: https://github.com/xtermjs/xterm.js (fix pending). Applied here as a - * string patch on the minified build, like patch-xterm-webgl-atlas.cjs, so a - * version bump that moves the target surfaces as an install failure rather than - * silently losing the fix. + * string patch on the minified build, like patch-xterm-webgl-atlas.cjs. The + * exact package version and complete refreshRows method are checked so an xterm + * upgrade or minifier change fails installation rather than mispatching code. * - * Idempotent. + * Idempotent. Both builds are validated before either is replaced. */ "use strict"; const fs = require("node:fs"); const path = require("node:path"); const MARKER = "/*netcatty:sync-render*/"; +const EXPECTED_VERSION = "6.1.0-beta.220"; +const VERSION_FILE = "node_modules/@xterm/xterm/package.json"; -// The minified `sync ? _renderRows(a,b) : _renderDebouncer.refresh(a,b,c)` -// ternary, and the `buffered` local to widen it with. Token names differ -// between the CJS and ESM builds, so each target names its own. const TARGETS = [ { file: "node_modules/@xterm/xterm/lib/xterm.js", - // const r = flush(); ... i ? _renderRows(e,t) : _renderDebouncer.refresh(e,t,this._rowCount) - from: "i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", - to: "(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + from: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", }, { file: "node_modules/@xterm/xterm/lib/xterm.mjs", - // let o = flush(); ... r ? _renderRows(e,t) : _renderDebouncer.refresh(e,t,this._rowCount) - from: "r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", - to: "(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + from: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", }, ]; -let patched = 0; let already = 0; let upstream = 0; let missing = 0; +const writes = []; + +const count = (source, value) => source.split(value).length - 1; +const warnInvalid = (file, detail) => { + console.warn(`[patch-xterm-sync-render] ERROR: ${detail} in ${file}. ` + + "Refresh the exact target before upgrading @xterm/xterm."); + missing++; +}; + +try { + const versionPath = path.resolve(process.cwd(), VERSION_FILE); + const version = JSON.parse(fs.readFileSync(versionPath, "utf8")).version; + if (version !== EXPECTED_VERSION) { + warnInvalid(VERSION_FILE, `expected version ${EXPECTED_VERSION}, found ${version}`); + } +} catch { + warnInvalid(VERSION_FILE, "package version is missing or invalid"); +} for (const { file, from, to } of TARGETS) { const abs = path.resolve(process.cwd(), file); - let src; + let source; + let stat; try { - src = fs.readFileSync(abs, "utf8"); + source = fs.readFileSync(abs, "utf8"); + stat = fs.statSync(abs); } catch { - console.warn(`[patch-xterm-sync-render] skip (not found): ${file}`); - missing++; + warnInvalid(file, "target is missing"); continue; } - const withMarker = to + MARKER; - const markerMatches = src.split(MARKER).length - 1; - const targetMatches = src.split(from).length - 1; - const upstreamMatches = src.split(to).length - 1; - if (markerMatches === 1 && src.includes(withMarker)) { + + const marked = `${to.slice(0, -1)}${MARKER}}`; + const markerMatches = count(source, MARKER); + const targetMatches = count(source, from); + const upstreamMatches = count(source, to); + if (markerMatches === 1 && count(source, marked) === 1) { already++; - continue; - } - if (markerMatches === 0 && targetMatches === 1 && upstreamMatches === 0) { - fs.writeFileSync(abs, src.replace(from, withMarker), "utf8"); - patched++; + } else if (markerMatches === 0 && targetMatches === 1 && upstreamMatches === 0) { + writes.push({ abs, file, mode: stat.mode, source, output: source.replace(from, marked) }); } else if (markerMatches === 0 && targetMatches === 0 && upstreamMatches === 1) { - // The exact upstream fixed form is already present without Netcatty's - // marker. Leave it untouched so an xterm upgrade can retire this patch. upstream++; } else { - console.warn( - `[patch-xterm-sync-render] ERROR: sync-render ternary not found (or ambiguous) in ${file}. ` + - "Refresh the minified target before upgrading @xterm/xterm.", - ); + warnInvalid(file, "complete sync-render method was not found exactly once"); + } +} + +let patched = 0; +if (missing === 0 && writes.length > 0) { + const staged = []; + const committed = []; + try { + for (const write of writes) { + const temp = `${write.abs}.netcatty-${process.pid}-${staged.length}.tmp`; + fs.writeFileSync(temp, write.output, { encoding: "utf8", flag: "wx", mode: write.mode }); + staged.push({ ...write, temp }); + } + for (const write of staged) { + fs.renameSync(write.temp, write.abs); + committed.push(write); + } + patched = committed.length; + } catch (error) { + console.warn(`[patch-xterm-sync-render] ERROR: atomic replacement failed: ${error.message}`); missing++; + for (const write of committed.reverse()) { + try { + const rollback = `${write.abs}.netcatty-${process.pid}-rollback.tmp`; + fs.writeFileSync(rollback, write.source, { encoding: "utf8", flag: "wx", mode: write.mode }); + fs.renameSync(rollback, write.abs); + } catch (rollbackError) { + console.warn(`[patch-xterm-sync-render] ERROR: rollback failed for ${write.file}: ${rollbackError.message}`); + } + } + } finally { + for (const write of staged) { + try { + fs.rmSync(write.temp, { force: true }); + } catch {} + } } } diff --git a/scripts/patch-xterm-sync-render.test.cjs b/scripts/patch-xterm-sync-render.test.cjs index 49e776eaab..91e6113bae 100644 --- a/scripts/patch-xterm-sync-render.test.cjs +++ b/scripts/patch-xterm-sync-render.test.cjs @@ -11,21 +11,25 @@ const { promisify } = require("node:util"); const execFileAsync = promisify(execFile); const script = path.resolve(__dirname, "patch-xterm-sync-render.cjs"); const marker = "/*netcatty:sync-render*/"; +const version = "6.1.0-beta.220"; const targets = [ { file: "node_modules/@xterm/xterm/lib/xterm.js", - from: "i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", - to: "(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + from: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", }, { file: "node_modules/@xterm/xterm/lib/xterm.mjs", - from: "r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", - to: "(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + from: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", }, ]; -const makeTmp = (t) => { +const makeTmp = (t, packageVersion = version) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-xterm-sync-patch-")); + const packageFile = path.join(dir, "node_modules/@xterm/xterm/package.json"); + fs.mkdirSync(path.dirname(packageFile), { recursive: true }); + fs.writeFileSync(packageFile, JSON.stringify({ version: packageVersion })); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return dir; }; @@ -46,7 +50,7 @@ test("patches both xterm builds and is idempotent", async (t) => { for (const target of targets) { const source = fs.readFileSync(path.join(root, target.file), "utf8"); assert.equal(source.includes(target.from), false); - assert.equal(source.includes(`${target.to}${marker}`), true); + assert.equal(source.includes(`${target.to.slice(0, -1)}${marker}}`), true); } const afterFirstRun = targets.map((target) => @@ -73,15 +77,35 @@ test("leaves the exact upstream fix untouched", async (t) => { } }); -test("fails closed when a target is missing or ambiguous", async (t) => { +test("validates every build before changing either one", async (t) => { const root = makeTmp(t); - writeBuild(root, targets[0], `${targets[0].from} ${targets[0].from}`); + writeBuild(root, targets[0]); writeBuild(root, targets[1], "unknown xterm build"); + const original = fs.readFileSync(path.join(root, targets[0].file), "utf8"); await assert.rejects(execFileAsync(process.execPath, [script], { cwd: root }), (error) => { assert.equal(error.code, 1); - assert.match(error.stdout, /patched=0 already=0 upstream=0 missing=2/); - assert.match(error.stderr, /sync-render ternary not found \(or ambiguous\)/); + assert.match(error.stdout, /patched=0 already=0 upstream=0 missing=1/); return true; }); + assert.equal(fs.readFileSync(path.join(root, targets[0].file), "utf8"), original); +}); + +test("rejects ambiguous, out-of-context, and unexpected-version builds", async (t) => { + const cases = [ + { version, cjs: `${targets[0].from} ${targets[0].from}`, esm: targets[1].from }, + { version, cjs: "i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", esm: targets[1].from }, + { version: "6.1.0-beta.221", cjs: targets[0].from, esm: targets[1].from }, + ]; + + for (const entry of cases) { + const root = makeTmp(t, entry.version); + writeBuild(root, targets[0], entry.cjs); + writeBuild(root, targets[1], entry.esm); + await assert.rejects(execFileAsync(process.execPath, [script], { cwd: root }), (error) => { + assert.equal(error.code, 1); + assert.match(error.stdout, /patched=0/); + return true; + }); + } }); diff --git a/scripts/xterm-sync-render.live.test.cjs b/scripts/xterm-sync-render.live.test.cjs index f089232bf2..1731d62ad4 100644 --- a/scripts/xterm-sync-render.live.test.cjs +++ b/scripts/xterm-sync-render.live.test.cjs @@ -1,30 +1,64 @@ "use strict"; -if (!process.versions.electron) { +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const LIVE_ENV = "NETCATTY_XTERM_SYNC_RENDER_LIVE"; +const USER_DATA_ENV = "NETCATTY_XTERM_SYNC_RENDER_USER_DATA"; + +if (!process.versions.electron && process.env[LIVE_ENV] !== "1") { const test = require("node:test"); test("closed synchronized-output frames render before the next frame opens", { - skip: "run with Electron so xterm's renderer is available", + skip: "run npm run test:xterm-sync-render for the Electron behavior test", }, () => {}); +} else if (!process.versions.electron) { + const { spawnSync } = require("node:child_process"); + const electronPath = require("electron"); + const userData = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-xterm-sync-render-")); + const result = spawnSync(electronPath, [__filename], { + cwd: path.resolve(__dirname, ".."), + env: { ...process.env, [USER_DATA_ENV]: userData }, + stdio: "inherit", + timeout: 30_000, + }); + fs.rmSync(userData, { recursive: true, force: true }); + if (result.error) { + console.error(result.error); + process.exitCode = 1; + } else if (result.status !== 0) { + process.exitCode = result.status ?? 1; + } } else { const assert = require("node:assert/strict"); - const fs = require("node:fs"); - const os = require("node:os"); - const path = require("node:path"); + const { pathToFileURL } = require("node:url"); const electron = require("electron"); const appRoot = path.resolve(__dirname, ".."); - const userData = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-xterm-sync-render-")); + const userData = process.env[USER_DATA_ENV]; + assert.ok(userData, `${USER_DATA_ENV} is required`); electron.app.setPath("userData", userData); electron.app.commandLine.appendSwitch("disable-gpu"); - electron.app.on("window-all-closed", () => {}); - const cleanup = (exitCode) => { - fs.rmSync(userData, { recursive: true, force: true }); - electron.app.exit(exitCode); + let window; + let finished = false; + const finish = (exitCode, error) => { + if (finished) return; + finished = true; + clearTimeout(hardTimeout); + if (error) console.error(error); + try { + if (window && !window.isDestroyed()) window.destroy(); + } finally { + electron.app.exit(exitCode); + } }; + const hardTimeout = setTimeout(() => { + finish(1, new Error("xterm synchronized-render Electron test timed out")); + }, 20_000); void electron.app.whenReady().then(async () => { - const window = new electron.BrowserWindow({ + window = new electron.BrowserWindow({ show: false, width: 640, height: 360, @@ -35,55 +69,57 @@ if (!process.versions.electron) { sandbox: false, }, }); - await window.loadURL( - "data:text/html;charset=utf-8," + encodeURIComponent( - "
", - ), + const htmlFile = path.join(userData, "xterm-sync-render.html"); + fs.writeFileSync( + htmlFile, + "
", ); + await window.loadFile(htmlFile); - const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] }); - const result = await window.webContents.executeJavaScript(`(async () => { - const { Terminal } = require(${JSON.stringify(xtermPath)}); - const term = new Terminal({ cols: 10, rows: 2, cursorBlink: false }); - term.open(document.getElementById("terminal")); + const cjsPath = require.resolve("@xterm/xterm", { paths: [appRoot] }); + const esmPath = path.join(path.dirname(cjsPath), "xterm.mjs"); + const loaders = [ + { name: "cjs", expression: `require(${JSON.stringify(cjsPath)})` }, + { name: "esm", expression: `await import(${JSON.stringify(pathToFileURL(esmPath).href)})` }, + ]; + const results = []; - const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)); - await nextFrame(); - await nextFrame(); + for (const loader of loaders) { + const result = await window.webContents.executeJavaScript(`(async () => { + const { Terminal } = ${loader.expression}; + const target = document.getElementById("terminal"); + target.replaceChildren(); + const term = new Terminal({ cols: 10, rows: 2, cursorBlink: false }); + term.open(target); - const renders = []; - const renderSubscription = term.onRender(event => renders.push(event)); - const write = data => new Promise(resolve => term.write(data, resolve)); - const firstFrameStart = "\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA"; - const firstFrameClose = "\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l"; - const secondFrameOpen = "\\x1b[?2026h\\x1b[1;1HCC"; + const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)); + await nextFrame(); + await nextFrame(); - // Keep the synchronized frame open across two input chunks so xterm - // buffers dirty rows, then queue the next frame before the completed - // first frame's debounced paint can run. - await write(firstFrameStart); - await write(firstFrameClose); - await write(secondFrameOpen); - await nextFrame(); - await nextFrame(); - const rendersBeforeSecondFrameClose = renders.length; + const renders = []; + const renderSubscription = term.onRender(event => renders.push(event)); + const write = data => new Promise(resolve => term.write(data, resolve)); + await write("\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA"); + await write("\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l"); + await write("\\x1b[?2026h\\x1b[1;1HCC"); + await nextFrame(); + await nextFrame(); + const rendersBeforeSecondFrameClose = renders.length; - await write("\\x1b[?2026l"); - await nextFrame(); - renderSubscription.dispose(); - term.dispose(); - return { rendersBeforeSecondFrameClose }; - })()`); + await write("\\x1b[?2026l"); + await nextFrame(); + renderSubscription.dispose(); + term.dispose(); + return { rendersBeforeSecondFrameClose }; + })()`); + assert.ok( + result.rendersBeforeSecondFrameClose > 0, + `${loader.name} did not render the completed first frame before the second opened: ${JSON.stringify(result)}`, + ); + results.push({ build: loader.name, ...result }); + } - assert.ok( - result.rendersBeforeSecondFrameClose > 0, - `the completed first frame was not rendered before the second frame opened: ${JSON.stringify(result)}`, - ); - process.stdout.write(`XTERM_SYNC_RENDER_OK ${JSON.stringify(result)}\n`); - window.destroy(); - cleanup(0); - }).catch((error) => { - console.error(error); - cleanup(1); - }); + process.stdout.write(`XTERM_SYNC_RENDER_OK ${JSON.stringify(results)}\n`); + finish(0); + }).catch((error) => finish(1, error)); } From a6bdae1ca79ee4906a829cc7a8f4fea94df24b76 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:20:36 +0800 Subject: [PATCH 4/6] fix(terminal): render every completed sync frame --- scripts/patch-xterm-sync-render.cjs | 127 ++++++++++++++++------- scripts/patch-xterm-sync-render.test.cjs | 84 +++++++++++---- scripts/xterm-sync-render.live.test.cjs | 43 ++++++-- 3 files changed, 183 insertions(+), 71 deletions(-) diff --git a/scripts/patch-xterm-sync-render.cjs b/scripts/patch-xterm-sync-render.cjs index 06b53c7d87..ba63e00fe1 100644 --- a/scripts/patch-xterm-sync-render.cjs +++ b/scripts/patch-xterm-sync-render.cjs @@ -1,49 +1,75 @@ #!/usr/bin/env node /* global process, console */ /** - * Render a DEC 2026 synchronized-output frame the moment it closes, instead of - * on the next debounced tick. + * Render each completed DEC 2026 synchronized-output frame immediately. * - * xterm's RenderService buffers rows while synchronized output is on and, on - * close, requests a refresh that is scheduled through the render debouncer - * (requestAnimationFrame). Under a continuous full-screen animation the next - * frame opens a new 2026 block before that rAF fires, and `_renderRows` skips - * while sync is on — so the debounced paint is dropped and the frame only - * appears when the 1000ms synchronized-output timeout expires. The display is - * then pinned at ~1fps however fast frames arrive. + * xterm normally routes a mode-close refresh through requestAnimationFrame. + * If the next synchronized frame starts before that callback, rendering is + * suppressed until xterm's one-second safety timeout. This patch marks the + * mode-close refresh as synchronous and carries that signal to RenderService. + * It also renders a flushed synchronized-output buffer synchronously, matching + * the pending upstream proposal for frames split across input chunks. * - * The fix renders synchronously when a synchronized-output buffer was just - * flushed. `refreshRows(...,sync,...)` normally does - * `sync ? _renderRows(...) : _renderDebouncer.refresh(...)`; we widen the - * condition to also render synchronously when the flush returned buffered rows - * (the local holding `_syncOutputHandler.flush()`). At that point the mode is - * already off, so the completed frame paints before the next can reopen it. + * Upstream: https://github.com/xtermjs/xterm.js/pull/6073. Applied to the + * installed minified builds like patch-xterm-webgl-atlas.cjs. The exact package + * version and complete surrounding expressions are checked. Both CJS and ESM + * builds are validated and staged before either is atomically replaced. * - * Upstream: https://github.com/xtermjs/xterm.js (fix pending). Applied here as a - * string patch on the minified build, like patch-xterm-webgl-atlas.cjs. The - * exact package version and complete refreshRows method are checked so an xterm - * upgrade or minifier change fails installation rather than mispatching code. - * - * Idempotent. Both builds are validated before either is replaced. + * Idempotent. */ "use strict"; const fs = require("node:fs"); const path = require("node:path"); -const MARKER = "/*netcatty:sync-render*/"; const EXPECTED_VERSION = "6.1.0-beta.220"; const VERSION_FILE = "node_modules/@xterm/xterm/package.json"; +const REFRESH_MARKER = "/*netcatty:sync-render*/"; +const LISTENER_MARKER = "/*netcatty:sync-render-listener*/"; +const CLOSE_MARKER = "/*netcatty:sync-render-close*/"; + +const markedMethod = (value) => `${value.slice(0, -1)}${REFRESH_MARKER}}`; +const markedExpression = (value, marker) => `${value}${marker}`; const TARGETS = [ { file: "node_modules/@xterm/xterm/lib/xterm.js", - from: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", - to: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + edits: [ + { + from: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + mark: markedMethod, + }, + { + from: "this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1)))", + to: "this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1,e?.sync??!1)))", + mark: (value) => markedExpression(value, LISTENER_MARKER), + }, + { + from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break", + mark: (value) => markedExpression(value, CLOSE_MARKER), + }, + ], }, { file: "node_modules/@xterm/xterm/lib/xterm.mjs", - from: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", - to: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + edits: [ + { + from: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + mark: markedMethod, + }, + { + from: "this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1)))", + to: "this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1,t?.sync??!1)))", + mark: (value) => markedExpression(value, LISTENER_MARKER), + }, + { + from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break", + mark: (value) => markedExpression(value, CLOSE_MARKER), + }, + ], }, ]; @@ -55,7 +81,7 @@ const writes = []; const count = (source, value) => source.split(value).length - 1; const warnInvalid = (file, detail) => { console.warn(`[patch-xterm-sync-render] ERROR: ${detail} in ${file}. ` + - "Refresh the exact target before upgrading @xterm/xterm."); + "Refresh the exact targets before upgrading @xterm/xterm."); missing++; }; @@ -69,30 +95,51 @@ try { warnInvalid(VERSION_FILE, "package version is missing or invalid"); } -for (const { file, from, to } of TARGETS) { - const abs = path.resolve(process.cwd(), file); +for (const target of TARGETS) { + const abs = path.resolve(process.cwd(), target.file); let source; let stat; try { source = fs.readFileSync(abs, "utf8"); stat = fs.statSync(abs); } catch { - warnInvalid(file, "target is missing"); + warnInvalid(target.file, "target is missing"); continue; } - const marked = `${to.slice(0, -1)}${MARKER}}`; - const markerMatches = count(source, MARKER); - const targetMatches = count(source, from); - const upstreamMatches = count(source, to); - if (markerMatches === 1 && count(source, marked) === 1) { - already++; - } else if (markerMatches === 0 && targetMatches === 1 && upstreamMatches === 0) { - writes.push({ abs, file, mode: stat.mode, source, output: source.replace(from, marked) }); - } else if (markerMatches === 0 && targetMatches === 0 && upstreamMatches === 1) { + let output = source; + let markedEdits = 0; + let upstreamEdits = 0; + let pendingEdits = 0; + let invalid = false; + for (const edit of target.edits) { + const marked = edit.mark(edit.to); + const markedMatches = count(source, marked); + const fromMatches = count(source, edit.from); + const toMatches = count(source, edit.to); + if (markedMatches === 1) { + markedEdits++; + } else if (fromMatches === 1 && toMatches === 0) { + output = output.replace(edit.from, marked); + pendingEdits++; + } else if (fromMatches === 0 && toMatches === 1) { + upstreamEdits++; + } else { + invalid = true; + break; + } + } + + if (invalid || (upstreamEdits > 0 && upstreamEdits !== target.edits.length)) { + warnInvalid(target.file, "complete synchronized-render contexts were not found in one consistent state"); + } else if (upstreamEdits === target.edits.length) { upstream++; + } else if (markedEdits === target.edits.length) { + already++; + } else if (markedEdits + pendingEdits === target.edits.length && pendingEdits > 0) { + writes.push({ abs, file: target.file, mode: stat.mode, source, output }); } else { - warnInvalid(file, "complete sync-render method was not found exactly once"); + warnInvalid(target.file, "synchronized-render edits were incomplete"); } } diff --git a/scripts/patch-xterm-sync-render.test.cjs b/scripts/patch-xterm-sync-render.test.cjs index 91e6113bae..14d5fdac7b 100644 --- a/scripts/patch-xterm-sync-render.test.cjs +++ b/scripts/patch-xterm-sync-render.test.cjs @@ -10,18 +10,46 @@ const { promisify } = require("node:util"); const execFileAsync = promisify(execFile); const script = path.resolve(__dirname, "patch-xterm-sync-render.cjs"); -const marker = "/*netcatty:sync-render*/"; const version = "6.1.0-beta.220"; +const markers = [ + "/*netcatty:sync-render*/", + "/*netcatty:sync-render-listener*/", + "/*netcatty:sync-render-close*/", +]; const targets = [ { file: "node_modules/@xterm/xterm/lib/xterm.js", - from: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", - to: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + edits: [ + { + from: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + }, + { + from: "this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1)))", + to: "this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1,e?.sync??!1)))", + }, + { + from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break", + }, + ], }, { file: "node_modules/@xterm/xterm/lib/xterm.mjs", - from: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", - to: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + edits: [ + { + from: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + }, + { + from: "this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1)))", + to: "this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1,t?.sync??!1)))", + }, + { + from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break", + }, + ], }, ]; @@ -34,7 +62,11 @@ const makeTmp = (t, packageVersion = version) => { return dir; }; -const writeBuild = (root, target, source = target.from) => { +const sourceFor = (target, state) => target.edits + .map((edit, index) => state === "from" ? edit.from : `${edit.to}${markers[index]}`) + .join(" separator "); + +const writeBuild = (root, target, source = sourceFor(target, "from")) => { const file = path.join(root, target.file); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `prefix ${source} suffix`); @@ -47,15 +79,13 @@ test("patches both xterm builds and is idempotent", async (t) => { const first = await execFileAsync(process.execPath, [script], { cwd: root }); assert.match(first.stdout, /patched=2 already=0 upstream=0 missing=0/); assert.equal(first.stderr, ""); - for (const target of targets) { - const source = fs.readFileSync(path.join(root, target.file), "utf8"); - assert.equal(source.includes(target.from), false); - assert.equal(source.includes(`${target.to.slice(0, -1)}${marker}}`), true); - } - const afterFirstRun = targets.map((target) => fs.readFileSync(path.join(root, target.file), "utf8") ); + for (const source of afterFirstRun) { + for (const marker of markers) assert.equal(source.includes(marker), true); + } + const second = await execFileAsync(process.execPath, [script], { cwd: root }); assert.match(second.stdout, /patched=0 already=2 upstream=0 missing=0/); assert.deepEqual( @@ -64,16 +94,16 @@ test("patches both xterm builds and is idempotent", async (t) => { ); }); -test("leaves the exact upstream fix untouched", async (t) => { +test("leaves the complete upstream-equivalent fix untouched", async (t) => { const root = makeTmp(t); - for (const target of targets) writeBuild(root, target, target.to); - + for (const target of targets) { + writeBuild(root, target, target.edits.map((edit) => edit.to).join(" separator ")); + } const result = await execFileAsync(process.execPath, [script], { cwd: root }); assert.match(result.stdout, /patched=0 already=0 upstream=2 missing=0/); for (const target of targets) { const source = fs.readFileSync(path.join(root, target.file), "utf8"); - assert.equal(source.includes(target.to), true); - assert.equal(source.includes(marker), false); + for (const marker of markers) assert.equal(source.includes(marker), false); } }); @@ -91,11 +121,23 @@ test("validates every build before changing either one", async (t) => { assert.equal(fs.readFileSync(path.join(root, targets[0].file), "utf8"), original); }); -test("rejects ambiguous, out-of-context, and unexpected-version builds", async (t) => { +test("rejects partial, ambiguous, and unexpected-version builds", async (t) => { const cases = [ - { version, cjs: `${targets[0].from} ${targets[0].from}`, esm: targets[1].from }, - { version, cjs: "i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", esm: targets[1].from }, - { version: "6.1.0-beta.221", cjs: targets[0].from, esm: targets[1].from }, + { + version, + cjs: `${sourceFor(targets[0], "from")} ${targets[0].edits[0].from}`, + esm: sourceFor(targets[1], "from"), + }, + { + version, + cjs: targets[0].edits.map((edit, index) => index === 0 ? edit.to : edit.from).join(" separator "), + esm: sourceFor(targets[1], "from"), + }, + { + version: "6.1.0-beta.221", + cjs: sourceFor(targets[0], "from"), + esm: sourceFor(targets[1], "from"), + }, ]; for (const entry of cases) { diff --git a/scripts/xterm-sync-render.live.test.cjs b/scripts/xterm-sync-render.live.test.cjs index 1731d62ad4..41c53b5c3d 100644 --- a/scripts/xterm-sync-render.live.test.cjs +++ b/scripts/xterm-sync-render.live.test.cjs @@ -82,10 +82,34 @@ if (!process.versions.electron && process.env[LIVE_ENV] !== "1") { { name: "cjs", expression: `require(${JSON.stringify(cjsPath)})` }, { name: "esm", expression: `await import(${JSON.stringify(pathToFileURL(esmPath).href)})` }, ]; + const scenarios = [ + { + name: "split-input", + chunks: [ + "\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA", + "\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l", + "\\x1b[?2026h\\x1b[1;1HCC", + ], + }, + { + name: "close-and-next-open-together", + chunks: [ + "\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA", + "\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l\\x1b[?2026h\\x1b[1;1HCC", + ], + }, + { + name: "complete-and-next-frame-together", + chunks: [ + "\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l\\x1b[?2026h\\x1b[1;1HCC", + ], + }, + ]; const results = []; for (const loader of loaders) { - const result = await window.webContents.executeJavaScript(`(async () => { + for (const scenario of scenarios) { + const result = await window.webContents.executeJavaScript(`(async () => { const { Terminal } = ${loader.expression}; const target = document.getElementById("terminal"); target.replaceChildren(); @@ -99,9 +123,7 @@ if (!process.versions.electron && process.env[LIVE_ENV] !== "1") { const renders = []; const renderSubscription = term.onRender(event => renders.push(event)); const write = data => new Promise(resolve => term.write(data, resolve)); - await write("\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA"); - await write("\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l"); - await write("\\x1b[?2026h\\x1b[1;1HCC"); + for (const chunk of ${JSON.stringify(scenario.chunks)}) await write(chunk); await nextFrame(); await nextFrame(); const rendersBeforeSecondFrameClose = renders.length; @@ -111,12 +133,13 @@ if (!process.versions.electron && process.env[LIVE_ENV] !== "1") { renderSubscription.dispose(); term.dispose(); return { rendersBeforeSecondFrameClose }; - })()`); - assert.ok( - result.rendersBeforeSecondFrameClose > 0, - `${loader.name} did not render the completed first frame before the second opened: ${JSON.stringify(result)}`, - ); - results.push({ build: loader.name, ...result }); + })()`); + assert.ok( + result.rendersBeforeSecondFrameClose > 0, + `${loader.name}/${scenario.name} did not render the completed first frame before the second opened: ${JSON.stringify(result)}`, + ); + results.push({ build: loader.name, scenario: scenario.name, ...result }); + } } process.stdout.write(`XTERM_SYNC_RENDER_OK ${JSON.stringify(results)}\n`); From 5e305a1ab180754a1cb1a1aaafca515979c079ea Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:28:08 +0800 Subject: [PATCH 5/6] test(terminal): verify real synchronized output --- scripts/patch-xterm-sync-render.cjs | 15 ++++- scripts/patch-xterm-sync-render.test.cjs | 27 ++++++++- scripts/xterm-sync-render.live.test.cjs | 73 ++++++++++++++++++++---- 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/scripts/patch-xterm-sync-render.cjs b/scripts/patch-xterm-sync-render.cjs index ba63e00fe1..bf77388e44 100644 --- a/scripts/patch-xterm-sync-render.cjs +++ b/scripts/patch-xterm-sync-render.cjs @@ -46,8 +46,11 @@ const TARGETS = [ }, { from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", - to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput?(this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0})):this._onRequestRefreshRows.fire(void 0);break", mark: (value) => markedExpression(value, CLOSE_MARKER), + legacy: [ + "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break/*netcatty:sync-render-close*/", + ], }, ], }, @@ -66,8 +69,11 @@ const TARGETS = [ }, { from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", - to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput?(this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0})):this._onRequestRefreshRows.fire(void 0);break", mark: (value) => markedExpression(value, CLOSE_MARKER), + legacy: [ + "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break/*netcatty:sync-render-close*/", + ], }, ], }, @@ -117,8 +123,13 @@ for (const target of TARGETS) { const markedMatches = count(source, marked); const fromMatches = count(source, edit.from); const toMatches = count(source, edit.to); + const legacyMatches = edit.legacy?.filter((value) => count(source, value) > 0) ?? []; + const legacy = legacyMatches.find((value) => count(source, value) === 1); if (markedMatches === 1) { markedEdits++; + } else if (legacy && legacyMatches.length === 1 && fromMatches === 0 && toMatches === 0) { + output = output.replace(legacy, marked); + pendingEdits++; } else if (fromMatches === 1 && toMatches === 0) { output = output.replace(edit.from, marked); pendingEdits++; diff --git a/scripts/patch-xterm-sync-render.test.cjs b/scripts/patch-xterm-sync-render.test.cjs index 14d5fdac7b..60b12cae18 100644 --- a/scripts/patch-xterm-sync-render.test.cjs +++ b/scripts/patch-xterm-sync-render.test.cjs @@ -30,7 +30,8 @@ const targets = [ }, { from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", - to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput?(this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0})):this._onRequestRefreshRows.fire(void 0);break", + legacy: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break/*netcatty:sync-render-close*/", }, ], }, @@ -47,7 +48,8 @@ const targets = [ }, { from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", - to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput?(this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0})):this._onRequestRefreshRows.fire(void 0);break", + legacy: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break/*netcatty:sync-render-close*/", }, ], }, @@ -66,6 +68,12 @@ const sourceFor = (target, state) => target.edits .map((edit, index) => state === "from" ? edit.from : `${edit.to}${markers[index]}`) .join(" separator "); +const markedFor = (target) => target.edits + .map((edit, index) => index === 0 + ? `${edit.to.slice(0, -1)}${markers[index]}}` + : `${edit.to}${markers[index]}`) + .join(" separator "); + const writeBuild = (root, target, source = sourceFor(target, "from")) => { const file = path.join(root, target.file); fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -107,6 +115,21 @@ test("leaves the complete upstream-equivalent fix untouched", async (t) => { } }); +test("upgrades the previous unconditional close marker", async (t) => { + const root = makeTmp(t); + for (const target of targets) { + const source = markedFor(target).replace(`${target.edits[2].to}${markers[2]}`, target.edits[2].legacy); + writeBuild(root, target, source); + } + const result = await execFileAsync(process.execPath, [script], { cwd: root }); + assert.match(result.stdout, /patched=2 already=0 upstream=0 missing=0/); + for (const target of targets) { + const source = fs.readFileSync(path.join(root, target.file), "utf8"); + assert.equal(source.includes(target.edits[2].legacy), false); + assert.equal(source.includes(`${target.edits[2].to}${markers[2]}`), true); + } +}); + test("validates every build before changing either one", async (t) => { const root = makeTmp(t); writeBuild(root, targets[0]); diff --git a/scripts/xterm-sync-render.live.test.cjs b/scripts/xterm-sync-render.live.test.cjs index 41c53b5c3d..d767d4cc9c 100644 --- a/scripts/xterm-sync-render.live.test.cjs +++ b/scripts/xterm-sync-render.live.test.cjs @@ -6,6 +6,8 @@ const path = require("node:path"); const LIVE_ENV = "NETCATTY_XTERM_SYNC_RENDER_LIVE"; const USER_DATA_ENV = "NETCATTY_XTERM_SYNC_RENDER_USER_DATA"; +const MODULE_ROOT_ENV = "NETCATTY_XTERM_SYNC_RENDER_MODULE_ROOT"; +const EXPECT_UNPATCHED_ENV = "NETCATTY_XTERM_SYNC_RENDER_EXPECT_UNPATCHED"; if (!process.versions.electron && process.env[LIVE_ENV] !== "1") { const test = require("node:test"); @@ -76,7 +78,9 @@ if (!process.versions.electron && process.env[LIVE_ENV] !== "1") { ); await window.loadFile(htmlFile); - const cjsPath = require.resolve("@xterm/xterm", { paths: [appRoot] }); + const moduleRoot = process.env[MODULE_ROOT_ENV] || appRoot; + const expectUnpatched = process.env[EXPECT_UNPATCHED_ENV] === "1"; + const cjsPath = require.resolve("@xterm/xterm", { paths: [moduleRoot] }); const esmPath = path.join(path.dirname(cjsPath), "xterm.mjs"); const loaders = [ { name: "cjs", expression: `require(${JSON.stringify(cjsPath)})` }, @@ -86,25 +90,30 @@ if (!process.versions.electron && process.env[LIVE_ENV] !== "1") { { name: "split-input", chunks: [ - "\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA", - "\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l", - "\\x1b[?2026h\\x1b[1;1HCC", + "\x1b[?2026h\x1b[1;1HAAAAAAAAAA", + "\x1b[2;1HBBBBBBBBBB\x1b[?2026l", + "\x1b[?2026h\x1b[1;1HCC", ], }, { name: "close-and-next-open-together", chunks: [ - "\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA", - "\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l\\x1b[?2026h\\x1b[1;1HCC", + "\x1b[?2026h\x1b[1;1HAAAAAAAAAA", + "\x1b[2;1HBBBBBBBBBB\x1b[?2026l\x1b[?2026h\x1b[1;1HCC", ], }, { name: "complete-and-next-frame-together", chunks: [ - "\\x1b[?2026h\\x1b[1;1HAAAAAAAAAA\\x1b[2;1HBBBBBBBBBB\\x1b[?2026l\\x1b[?2026h\\x1b[1;1HCC", + "\x1b[?2026h\x1b[1;1HAAAAAAAAAA\x1b[2;1HBBBBBBBBBB\x1b[?2026l\x1b[?2026h\x1b[1;1HCC", ], }, ]; + for (const scenario of scenarios) { + for (const chunk of scenario.chunks) { + assert.equal(chunk.charCodeAt(0), 0x1b, `${scenario.name} must start with a real ESC byte`); + } + } const results = []; for (const loader of loaders) { @@ -134,15 +143,55 @@ if (!process.versions.electron && process.env[LIVE_ENV] !== "1") { term.dispose(); return { rendersBeforeSecondFrameClose }; })()`); - assert.ok( - result.rendersBeforeSecondFrameClose > 0, - `${loader.name}/${scenario.name} did not render the completed first frame before the second opened: ${JSON.stringify(result)}`, - ); + if (expectUnpatched) { + assert.equal( + result.rendersBeforeSecondFrameClose, + 0, + `${loader.name}/${scenario.name} unexpectedly passed without the patch: ${JSON.stringify(result)}`, + ); + } else { + assert.ok( + result.rendersBeforeSecondFrameClose > 0, + `${loader.name}/${scenario.name} did not render the completed first frame before the second opened: ${JSON.stringify(result)}`, + ); + } results.push({ build: loader.name, scenario: scenario.name, ...result }); } + + const redundantClose = await window.webContents.executeJavaScript(`(async () => { + const { Terminal } = ${loader.expression}; + const target = document.getElementById("terminal"); + target.replaceChildren(); + const term = new Terminal({ cols: 10, rows: 2, cursorBlink: false }); + term.open(target); + const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)); + await nextFrame(); + await nextFrame(); + const renders = []; + const renderSubscription = term.onRender(event => renders.push(event)); + await new Promise(resolve => term.write("\\x1b[?2026l".repeat(100), resolve)); + const immediate = renders.length; + await nextFrame(); + await nextFrame(); + const afterFrame = renders.length; + renderSubscription.dispose(); + term.dispose(); + return { immediate, afterFrame }; + })()`); + assert.equal( + redundantClose.immediate, + 0, + `${loader.name} rendered redundant synchronized-output closes immediately: ${JSON.stringify(redundantClose)}`, + ); + assert.ok( + redundantClose.afterFrame > 0, + `${loader.name} did not preserve the normal deferred refresh for redundant closes: ${JSON.stringify(redundantClose)}`, + ); + results.push({ build: loader.name, scenario: "redundant-close", ...redundantClose }); } - process.stdout.write(`XTERM_SYNC_RENDER_OK ${JSON.stringify(results)}\n`); + const label = expectUnpatched ? "XTERM_SYNC_RENDER_BASELINE_OK" : "XTERM_SYNC_RENDER_OK"; + process.stdout.write(`${label} ${JSON.stringify(results)}\n`); finish(0); }).catch((error) => finish(1, error)); } From 1d0b7904bd3a1e78c4b2110f90c020268a536af4 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:29:49 +0800 Subject: [PATCH 6/6] build: clear stale xterm prebundles after install --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 66509eceae..c895047cab 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never", "pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never", "pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never", - "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs && node scripts/patch-xterm-sync-render.cjs", + "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs && node scripts/patch-xterm-sync-render.cjs && node scripts/clean-vite-cache.cjs", "rebuild": "electron-builder install-app-deps", "tool:cli": "node electron/cli/netcatty-tool-cli.cjs", "generate:capability-tools": "node scripts/generate-capability-tools.cjs",