Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
binaricat marked this conversation as resolved.
Outdated
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
Expand All @@ -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": "cross-env NETCATTY_XTERM_SYNC_RENDER_LIVE=1 node scripts/xterm-sync-render.live.test.cjs"
},
"dependencies": {
"@eslint-community/regexpp": "4.12.2",
Expand Down
186 changes: 186 additions & 0 deletions scripts/patch-xterm-sync-render.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/usr/bin/env node
/* global process, console */
/**
* Render each completed DEC 2026 synchronized-output frame immediately.
*
* 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.
*
* 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.
*
* Idempotent.
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");

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",
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",
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),
},
],
},
];

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 targets 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 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(target.file, "target is missing");
continue;
}

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(target.file, "synchronized-render edits were incomplete");
}
}

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 {}
}
}
}

console.log(
`[patch-xterm-sync-render] patched=${patched} already=${already} upstream=${upstream} missing=${missing}`,
);

if (missing > 0) process.exitCode = 1;
153 changes: 153 additions & 0 deletions scripts/patch-xterm-sync-render.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"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 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",
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",
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",
},
],
},
];

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;
};

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`);
};

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, "");
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(
targets.map((target) => fs.readFileSync(path.join(root, target.file), "utf8")),
afterFirstRun,
);
});

test("leaves the complete upstream-equivalent fix untouched", async (t) => {
const root = makeTmp(t);
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");
for (const marker of markers) assert.equal(source.includes(marker), false);
}
});

test("validates every build before changing either one", async (t) => {
const root = makeTmp(t);
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=1/);
return true;
});
assert.equal(fs.readFileSync(path.join(root, targets[0].file), "utf8"), original);
});

test("rejects partial, ambiguous, and unexpected-version builds", async (t) => {
const cases = [
{
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) {
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;
});
}
});
Loading
Loading