fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:46:35 -05:00
1 parent c265758cdd
commit 0f6e48a95c
7 files changed
+341 -15

No files matched your search

@@ -135,7 +135,11 @@ export function GuestTerminalView({
terminal.open(xtermRef.current); terminal.open(xtermRef.current);
fitAddon.fit(); fitAddon.fit();
const resizeObserver = new ResizeObserver(() => fitAddon.fit()); let sharedSize: { cols: number; rows: number } | null = null;
const resizeObserver = new ResizeObserver(() => {
if (sharedSize) terminal.resize(sharedSize.cols, sharedSize.rows);
else fitAddon.fit();
});
resizeObserver.observe(xtermRef.current); resizeObserver.observe(xtermRef.current);
let cancelled = false; let cancelled = false;
@@ -163,6 +167,21 @@ export function GuestTerminalView({
} }
switch (msg.type) { switch (msg.type) {
case "resized": {
const { cols, rows } = msg;
if (
typeof cols === "number" &&
typeof rows === "number" &&
Number.isInteger(cols) &&
Number.isInteger(rows) &&
cols > 0 &&
rows > 0
) {
sharedSize = { cols, rows };
terminal.resize(cols, rows);
}
break;
}
case "data": case "data":
if (typeof msg.data === "string") terminal.write(msg.data); if (typeof msg.data === "string") terminal.write(msg.data);
break; break;
@@ -0,0 +1,110 @@
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GuestTerminalView } from "../../src/frontend/SharedSessionView";
const f = vi.hoisted(() => ({
terminal: {
cols: 80,
rows: 24,
options: {},
loadAddon: vi.fn(),
open: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
},
fit: vi.fn(),
onResize: null as (() => void) | null,
}));
vi.mock("@termix/plugin-sdk/frontend", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("react-xtermjs", () => ({
useXTerm: () => ({ instance: f.terminal, ref: { current: null } }),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {
f.fit();
}
},
}));
vi.mock("../../src/frontend/shared", () => ({
wsUrlForPath: async () => "ws://termix.test/terminal",
RemoteDisplay: () => null,
Loader: () => null,
}));
class Socket {
static OPEN = 1;
static latest: Socket;
readyState = 1;
onopen?: () => void;
onmessage?: (event: { data: string }) => void;
onclose?: () => void;
onerror?: () => void;
constructor() {
Socket.latest = this;
}
send() {}
close() {}
message(value: object) {
this.onmessage?.({ data: JSON.stringify(value) });
}
}
beforeEach(() => {
vi.clearAllMocks();
f.terminal.cols = 80;
f.terminal.rows = 24;
f.terminal.resize.mockImplementation((cols: number, rows: number) => {
f.terminal.cols = cols;
f.terminal.rows = rows;
});
f.fit.mockImplementation(() => {
f.terminal.cols = 80;
f.terminal.rows = 24;
});
vi.stubGlobal("WebSocket", Socket);
vi.stubGlobal(
"ResizeObserver",
class {
constructor(fn: () => void) {
f.onResize = fn;
}
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("keeps the presenter's grid while the guest window changes size", async () => {
render(
<GuestTerminalView
share={{ permissionLevel: "read-only" }}
wsPath="/plugin-ws/ssh-terminal/terminal?shareToken=test"
/>,
);
await waitFor(() => expect(Socket.latest.onmessage).toBeTypeOf("function"));
act(() => Socket.latest.message({ type: "resized", cols: 132, rows: 40 }));
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
act(() => f.onResize?.());
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
act(() => Socket.latest.message({ type: "resized", cols: 100, rows: 30 }));
expect([f.terminal.cols, f.terminal.rows]).toEqual([100, 30]);
});
describe("invalid shared terminal dimensions", () => {
it.each([0, -1, 1.5, "132"])("ignores cols=%s", async (cols) => {
render(
<GuestTerminalView
share={{ permissionLevel: "read-only" }}
wsPath="/plugin-ws/ssh-terminal/terminal?shareToken=test"
/>,
);
await waitFor(() => expect(Socket.latest.onmessage).toBeTypeOf("function"));
act(() => Socket.latest.message({ type: "resized", cols, rows: 24 }));
expect(f.terminal.resize).not.toHaveBeenCalled();
});
});
@@ -444,6 +444,15 @@ export class TerminalSessionManager {
tabInstanceId: opts.tabInstanceId, tabInstanceId: opts.tabInstanceId,
joinedViaShareId: opts.shareId, joinedViaShareId: opts.shareId,
}); });
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resized",
cols: session.cols,
rows: session.rows,
}),
);
}
this.broadcastParticipants(sessionId); this.broadcastParticipants(sessionId);
this.log.info("Participant joined shared session", { this.log.info("Participant joined shared session", {
@@ -812,6 +821,15 @@ export class TerminalSessionManager {
this.recordSessionEvent(session, "i", data); this.recordSessionEvent(session, "i", data);
} }
resizeSession(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.cols = cols;
session.rows = rows;
this.broadcast(sessionId, { type: "resized", cols, rows });
this.bufferResize(sessionId, cols, rows);
}
bufferResize(sessionId: string, cols: number, rows: number): void { bufferResize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return; if (!session) return;
@@ -568,8 +568,11 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
attachRows, attachRows,
attachCols, attachCols,
); );
session.cols = attachCols; sessionManager.resizeSession(
session.rows = attachRows; session.id,
attachCols,
attachRows,
);
} }
ws.send( ws.send(
@@ -2871,11 +2874,8 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
resizeStream.setWindow(rows, cols, rows, cols); resizeStream.setWindow(rows, cols, rows, cols);
const session = sessionManager.getSession(currentSessionId); const session = sessionManager.getSession(currentSessionId);
if (session) { if (session) {
session.cols = cols; sessionManager.resizeSession(session.id, cols, rows);
session.rows = rows;
sessionManager.bufferResize(session.id, cols, rows);
} }
ws.send(JSON.stringify({ type: "resized", cols, rows }));
} }
} }
@@ -910,6 +910,33 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
} }
const sharedSizeRef = useRef<{ cols: number; rows: number } | null>(null);
function applySharedSize(msg: { cols?: unknown; rows?: unknown }) {
if (!hostConfig.joinShareId || !terminal) return;
const { cols, rows } = msg;
if (
typeof cols !== "number" ||
typeof rows !== "number" ||
!Number.isInteger(cols) ||
!Number.isInteger(rows) ||
cols < 1 ||
rows < 1
)
return;
sharedSizeRef.current = { cols, rows };
terminal.resize(cols, rows);
}
function fitTerminal() {
const size = sharedSizeRef.current;
if (hostConfig.joinShareId && size) {
terminal?.resize(size.cols, size.rows);
} else {
fitAddonRef.current?.fit();
}
}
function performFit() { function performFit() {
if ( if (
!fitAddonRef.current || !fitAddonRef.current ||
@@ -923,7 +950,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
isFittingRef.current = true; isFittingRef.current = true;
try { try {
fitAddonRef.current.fit(); fitTerminal();
if (terminal && terminal.cols > 0 && terminal.rows > 0) { if (terminal && terminal.cols > 0 && terminal.rows > 0) {
const lastSize = lastFittedSizeRef.current; const lastSize = lastFittedSizeRef.current;
if ( if (
@@ -1180,6 +1207,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
function scheduleNotify(cols: number, rows: number) { function scheduleNotify(cols: number, rows: number) {
if (hostConfig.joinShareId) return;
if (!(cols > 0 && rows > 0)) return; if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = { cols, rows }; pendingSizeRef.current = { cols, rows };
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
@@ -1390,7 +1418,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (!fitAddonRef.current || !terminal || isFittingRef.current) return; if (!fitAddonRef.current || !terminal || isFittingRef.current) return;
isFittingRef.current = true; isFittingRef.current = true;
try { try {
fitAddonRef.current.fit(); fitTerminal();
if (terminal.cols > 0 && terminal.rows > 0) { if (terminal.cols > 0 && terminal.rows > 0) {
const lastSize = lastFittedSizeRef.current; const lastSize = lastFittedSizeRef.current;
if ( if (
@@ -1839,7 +1867,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
pongReceivedRef.current = true; pongReceivedRef.current = true;
return; return;
} }
if (msg.type === "data") { if (msg.type === "resized") {
applySharedSize(msg);
} else if (msg.type === "data") {
if (typeof msg.data === "string") { if (typeof msg.data === "string") {
outputListenersRef.current.forEach((listener) => outputListenersRef.current.forEach((listener) =>
listener(msg.data), listener(msg.data),
@@ -2674,7 +2704,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}); });
document.fonts.ready.then(() => { document.fonts.ready.then(() => {
terminal.refresh(0, terminal.rows - 1); terminal.refresh(0, terminal.rows - 1);
fitAddon.fit(); fitTerminal();
}); });
terminal.attachCustomWheelEventHandler((ev) => { terminal.attachCustomWheelEventHandler((ev) => {
@@ -2699,12 +2729,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return true; return true;
}); });
fitAddonRef.current?.fit(); fitTerminal();
// Double-rAF ensures layout is fully settled (fonts, flexbox, etc.) before // Double-rAF ensures layout is fully settled (fonts, flexbox, etc.) before
// committing the fitted size, preventing the "terminal too short" glitch. // committing the fitted size, preventing the "terminal too short" glitch.
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
fitAddonRef.current?.fit(); fitTerminal();
setIsFitted(true); setIsFitted(true);
}); });
}); });
@@ -3383,10 +3413,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
setIsConnecting(true); setIsConnecting(true);
fitAddonRef.current?.fit(); fitTerminal();
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
fitAddonRef.current?.fit(); fitTerminal();
if (terminal.cols > 0 && terminal.rows > 0) { if (terminal.cols > 0 && terminal.rows > 0) {
scheduleNotify(terminal.cols, terminal.rows); scheduleNotify(terminal.cols, terminal.rows);
connectToHost(terminal.cols, terminal.rows); connectToHost(terminal.cols, terminal.rows);
@@ -182,6 +182,46 @@ describe("TerminalSessionManager - multiplayer participants", () => {
return id; return id;
} }
it("sends the presenter dimensions before a participant replays output", () => {
const id = createConnectedSession();
const session = sessionManager.getSession(id)!;
session.cols = 132;
session.rows = 40;
const ws = makeFakeWs();
sessionManager.joinAsParticipant(id, ws, {
userId: "viewer",
permissionLevel: "read-only",
});
expect(JSON.parse(vi.mocked(ws.send).mock.calls[0][0] as string)).toEqual({
type: "resized",
cols: 132,
rows: 40,
});
sessionManager.destroySession(id);
});
it("broadcasts later size changes and retains them for new participants", () => {
const id = createConnectedSession();
const owner = makeFakeWs();
const viewer = makeFakeWs();
sessionManager.attachWs(id, "owner-1", owner);
sessionManager.joinAsParticipant(id, viewer, {
userId: "viewer",
permissionLevel: "read-only",
});
sessionManager.resizeSession(id, 100, 35);
const message = JSON.stringify({ type: "resized", cols: 100, rows: 35 });
expect(owner.send).toHaveBeenCalledWith(message);
expect(viewer.send).toHaveBeenCalledWith(message);
const newcomer = makeFakeWs();
sessionManager.joinAsParticipant(id, newcomer, {
userId: null,
permissionLevel: "read-only",
});
expect(vi.mocked(newcomer.send).mock.calls[0][0]).toBe(message);
sessionManager.destroySession(id);
});
it("joinAsParticipant adds a participant without evicting the owner", () => { it("joinAsParticipant adds a participant without evicting the owner", () => {
const id = createConnectedSession(); const id = createConnectedSession();
const ownerWs = makeFakeWs(); const ownerWs = makeFakeWs();
@@ -0,0 +1,109 @@
import ts from "typescript";
import { describe, expect, it, vi } from "vitest";
import sourceText from "../../../src/frontend/terminal/Terminal.tsx?raw";
const source = ts.createSourceFile(
"Terminal.tsx",
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const names = [
"setupWebSocketListeners",
"applySharedSize",
"fitTerminal",
"scheduleNotify",
];
const functions: string[] = [];
function visit(node: ts.Node) {
if (
ts.isFunctionDeclaration(node) &&
node.name &&
names.includes(node.name.text)
)
functions.push(node.getText(source));
ts.forEachChild(node, visit);
}
visit(source);
if (functions.length !== names.length)
throw new Error("Missing terminal size handlers");
const create = new Function(
"state",
ts.transpileModule(
`
const { terminal, hostConfig, sharedSizeRef, fitAddonRef, webSocketRef, emitSessionMessage } = state;
const connectionAttemptIdRef = { current: 1 };
${functions.join("\n")}
setupWebSocketListeners(webSocketRef.current, 80, 24, hostConfig);
return { fit: fitTerminal, notify: scheduleNotify };
`,
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
).outputText,
) as (state: Record<string, unknown>) => {
fit: () => void;
notify: (cols: number, rows: number) => void;
};
function fixture(shared = true) {
const events = new Map<string, (event: { data: string }) => void>();
const terminal = {
cols: 80,
rows: 24,
resize: vi.fn((cols: number, rows: number) => {
terminal.cols = cols;
terminal.rows = rows;
}),
};
const fit = vi.fn(() => {
terminal.cols = 80;
terminal.rows = 24;
});
const socket = {
addEventListener: (name: string, fn: (event: { data: string }) => void) =>
events.set(name, fn),
send: vi.fn(),
};
const handlers = create({
terminal,
hostConfig: shared ? { joinShareId: "share" } : {},
sharedSizeRef: { current: null },
fitAddonRef: { current: { fit } },
webSocketRef: { current: socket },
emitSessionMessage: vi.fn(),
});
const resize = (cols: unknown, rows: unknown) =>
events.get("message")!({
data: JSON.stringify({ type: "resized", cols, rows }),
});
return { terminal, fit, handlers, socket, resize };
}
describe("in-app shared terminal sizing", () => {
it("uses presenter sizes and does not refit or resize the remote shell from the viewer", () => {
const f = fixture();
f.resize(132, 40);
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
f.handlers.fit();
expect(f.fit).not.toHaveBeenCalled();
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
f.handlers.notify(80, 24);
expect(f.socket.send).not.toHaveBeenCalled();
f.resize(100, 30);
expect([f.terminal.cols, f.terminal.rows]).toEqual([100, 30]);
});
it("continues fitting the owner's terminal normally", () => {
const f = fixture(false);
f.resize(132, 40);
expect(f.terminal.resize).not.toHaveBeenCalled();
f.handlers.fit();
expect(f.fit).toHaveBeenCalledOnce();
});
it.each([0, -1, 1.5, "132"])("rejects an invalid column count %s", (cols) => {
const f = fixture();
f.resize(cols, 24);
expect(f.terminal.resize).not.toHaveBeenCalled();
});
});