Sync CloudShell backend PTY size on terminal resize (#2553)

* Sync CloudShell backend PTY size on terminal resize

Resizing the browser window only re-fit the local xterm; the remote shell kept its original column count, so typed input wrapped/broke at the wrong column. Add resizeTerminal() client call to the CloudShell terminals/{id}/size endpoint and register a debounced terminal.onResize handler that pushes new dimensions to the backend.

* Add unit tests for registerTerminalResizeHandler (debounce, no-op skip, error handling)

* Fix resizeTerminal success test: stub a successful fetch response
This commit is contained in:
Mike Krüger
2026-08-17 10:15:33 +02:00
committed by GitHub
parent 2834bf0fd4
commit 12ad23a061
4 changed files with 252 additions and 2 deletions
@@ -0,0 +1,119 @@
import { Terminal } from "xterm";
import { registerTerminalResizeHandler } from "./CloudShellTerminalCore";
import { resizeTerminal } from "./Data/CloudShellClient";
// Mock the CloudShell client so we can assert on backend resize calls without any network access.
jest.mock("./Data/CloudShellClient");
const mockResizeTerminal = resizeTerminal as jest.Mock;
const CONSOLE_URI = "https://shell.azure.com/console123";
const TERMINAL_ID = "terminal-id";
const DEBOUNCE_MS = 300;
type ResizeListener = (size: { cols: number; rows: number }) => void;
interface MockTerminal {
cols: number;
rows: number;
onResize: (listener: ResizeListener) => void;
emitResize: (size: { cols: number; rows: number }) => void;
}
const createMockTerminal = (cols: number, rows: number): MockTerminal => {
let listener: ResizeListener | undefined;
return {
cols,
rows,
onResize: (cb: ResizeListener) => {
listener = cb;
},
emitResize: (size: { cols: number; rows: number }) => listener?.(size),
};
};
const registerHandler = (terminal: MockTerminal): void =>
registerTerminalResizeHandler(terminal as unknown as Terminal, CONSOLE_URI, TERMINAL_ID);
describe("registerTerminalResizeHandler", () => {
beforeEach(() => {
jest.useFakeTimers();
mockResizeTerminal.mockReset();
mockResizeTerminal.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it("does not notify the backend when the dimensions are unchanged", () => {
const terminal = createMockTerminal(80, 24);
registerHandler(terminal);
terminal.emitResize({ cols: 80, rows: 24 });
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(mockResizeTerminal).not.toHaveBeenCalled();
});
it("notifies the backend with the new dimensions after the debounce interval", () => {
const terminal = createMockTerminal(80, 24);
registerHandler(terminal);
terminal.emitResize({ cols: 120, rows: 40 });
// Nothing should be sent until the debounce interval elapses.
expect(mockResizeTerminal).not.toHaveBeenCalled();
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(mockResizeTerminal).toHaveBeenCalledTimes(1);
expect(mockResizeTerminal).toHaveBeenCalledWith(CONSOLE_URI, TERMINAL_ID, { cols: 120, rows: 40 });
});
it("debounces rapid resizes into a single call with the latest dimensions", () => {
const terminal = createMockTerminal(80, 24);
registerHandler(terminal);
terminal.emitResize({ cols: 100, rows: 30 });
terminal.emitResize({ cols: 110, rows: 35 });
terminal.emitResize({ cols: 120, rows: 40 });
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(mockResizeTerminal).toHaveBeenCalledTimes(1);
expect(mockResizeTerminal).toHaveBeenCalledWith(CONSOLE_URI, TERMINAL_ID, { cols: 120, rows: 40 });
});
it("sends a separate call for each resize that is spaced beyond the debounce interval", () => {
const terminal = createMockTerminal(80, 24);
registerHandler(terminal);
terminal.emitResize({ cols: 100, rows: 30 });
jest.advanceTimersByTime(DEBOUNCE_MS);
terminal.emitResize({ cols: 120, rows: 40 });
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(mockResizeTerminal).toHaveBeenCalledTimes(2);
expect(mockResizeTerminal).toHaveBeenNthCalledWith(1, CONSOLE_URI, TERMINAL_ID, { cols: 100, rows: 30 });
expect(mockResizeTerminal).toHaveBeenNthCalledWith(2, CONSOLE_URI, TERMINAL_ID, { cols: 120, rows: 40 });
});
it("swallows backend resize errors and logs a warning", async () => {
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
mockResizeTerminal.mockRejectedValueOnce(new Error("boom"));
const terminal = createMockTerminal(80, 24);
registerHandler(terminal);
terminal.emitResize({ cols: 120, rows: 40 });
jest.advanceTimersByTime(DEBOUNCE_MS);
// Flush the microtask queue so the rejected promise's .catch handler runs.
await Promise.resolve();
expect(mockResizeTerminal).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith("CloudShell: failed to resize backend terminal", expect.any(Error));
warnSpy.mockRestore();
});
});
@@ -10,6 +10,7 @@ import {
provisionConsole,
putEphemeralUserSettings,
registerCloudShellProvider,
resizeTerminal,
verifyCloudShellProviderRegistration,
} from "./Data/CloudShellClient";
import { CloudShellProviderInfo, ProvisionConsoleResponse } from "./Models/DataModels";
@@ -26,6 +27,7 @@ const DEFAULT_FAIRFAX_CLOUDSHELL_REGION = "usgovvirginia";
const POLLING_INTERVAL_MS = 2000;
const MAX_RETRY_COUNT = 10;
const MAX_PING_COUNT = 120 * 60; // 120 minutes (60 seconds/minute)
const TERMINAL_RESIZE_DEBOUNCE_MS = 300;
let pingCount = 0;
let keepAliveID: NodeJS.Timeout = null;
@@ -96,6 +98,7 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter
socketUri?: string;
provisionConsoleResponse?: ProvisionConsoleResponse;
targetUri?: string;
terminalId?: string;
} = await provisionCloudShellSession(resolvedRegion, terminal);
if (!sessionDetails.socketUri) {
@@ -108,6 +111,14 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter
// Configure WebSocket connection with shell-specific commands
const socket = await establishTerminalConnection(terminal, shellHandler, sessionDetails.socketUri);
// Keep the backend PTY size in sync with the frontend terminal. Without this, resizing the
// browser window only re-fits the local xterm while the remote shell keeps its original
// column count, causing typed input to wrap/break at the wrong column.
const consoleUri = sessionDetails.provisionConsoleResponse?.properties?.uri;
if (consoleUri && sessionDetails.terminalId) {
registerTerminalResizeHandler(terminal, consoleUri, sessionDetails.terminalId);
}
TelemetryProcessor.traceSuccess(
Action.CloudShellTerminalSession,
{
@@ -159,13 +170,46 @@ export const determineCloudShellRegion = (): string => {
return getNormalizedRegion(userContext.databaseAccount?.location, defaultRegion);
};
/**
* Registers a debounced handler that notifies the CloudShell backend whenever the frontend
* terminal is resized (e.g. after a browser window resize triggers FitAddon.fit()), keeping the
* remote PTY column/row count in sync with what the user sees.
*/
export const registerTerminalResizeHandler = (terminal: Terminal, consoleUri: string, terminalId: string): void => {
let debounceTimer: NodeJS.Timeout | null = null;
let lastCols = terminal.cols;
let lastRows = terminal.rows;
terminal.onResize(({ cols, rows }) => {
if (cols === lastCols && rows === lastRows) {
return;
}
lastCols = cols;
lastRows = rows;
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
resizeTerminal(consoleUri, terminalId, { cols, rows }).catch((err) => {
console.warn("CloudShell: failed to resize backend terminal", err);
});
}, TERMINAL_RESIZE_DEBOUNCE_MS);
});
};
/**
* Provisions a CloudShell session
*/
export const provisionCloudShellSession = async (
resolvedRegion: string,
terminal: Terminal,
): Promise<{ socketUri?: string; provisionConsoleResponse?: ProvisionConsoleResponse; targetUri?: string }> => {
): Promise<{
socketUri?: string;
provisionConsoleResponse?: ProvisionConsoleResponse;
targetUri?: string;
terminalId?: string;
}> => {
// Apply user settings
await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion);
@@ -217,7 +261,7 @@ export const provisionCloudShellSession = async (
socketUri = `wss://${targetUriBodyArr[0]}/$hc/${targetUriBodyArr[1]}/terminals/${termId}`;
}
return { socketUri, provisionConsoleResponse, targetUri };
return { socketUri, provisionConsoleResponse, targetUri, terminalId: termId };
};
/**
@@ -6,6 +6,7 @@ import {
provisionConsole,
putEphemeralUserSettings,
registerCloudShellProvider,
resizeTerminal,
verifyCloudShellProviderRegistration,
} from "./CloudShellClient";
@@ -340,4 +341,66 @@ describe("CloudShellClient", () => {
);
});
});
describe("resizeTerminal", () => {
it("should call fetch with correct parameters", async () => {
const consoleUri = "https://shell.azure.com/console123";
const terminalId = "terminal-id";
const size = { rows: 40, cols: 120 };
global.fetch = jest.fn().mockImplementationOnce(() => {
return {
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({}),
text: jest.fn().mockResolvedValue(""),
headers: new Headers(),
} as unknown as Promise<Response>;
});
await resizeTerminal(consoleUri, terminalId, size);
expect(global.fetch).toHaveBeenCalledWith(
"https://shell.azure.com/console123/terminals/terminal-id/size?cols=120&rows=40&version=2019-01-01",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Content-Length": "2",
Authorization: "Bearer mock-token",
"x-ms-client-request-id": "mocked-uuid",
"Accept-Language": "en-US",
},
body: "{}",
},
);
});
it("should handle errors when terminal resize fails", async () => {
const consoleUri = "https://shell.azure.com/console123";
const terminalId = "terminal-id";
const size = { rows: 40, cols: 120 };
global.fetch = jest.fn().mockImplementationOnce(() => {
return {
ok: false,
status: 500,
statusText: "Internal Server Error",
json: jest.fn().mockRejectedValue(new Error("Failed to parse JSON")),
text: jest.fn().mockResolvedValue("Server Error"),
headers: new Headers(),
} as unknown as Promise<Response>;
});
await expect(resizeTerminal(consoleUri, terminalId, size)).rejects.toThrow(
"Failed to resize terminal: 500 Internal Server Error",
);
expect(global.fetch).toHaveBeenCalledWith(
"https://shell.azure.com/console123/terminals/terminal-id/size?cols=120&rows=40&version=2019-01-01",
expect.any(Object),
);
});
});
});
@@ -125,3 +125,27 @@ export const connectTerminal = async (
return resp.json();
};
export const resizeTerminal = async (
consoleUri: string,
terminalId: string,
size: { rows: number; cols: number },
): Promise<void> => {
const targetUri = consoleUri + `/terminals/${terminalId}/size?cols=${size.cols}&rows=${size.rows}&version=2019-01-01`;
const resp = await fetch(targetUri, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Content-Length": "2",
Authorization: userContext.authorizationToken,
"x-ms-client-request-id": uuidv4(),
"Accept-Language": getLocale(),
},
body: "{}", // empty body is necessary
});
if (!resp.ok) {
throw new Error(`Failed to resize terminal: ${resp.status} ${resp.statusText}`);
}
};