Skip to content

Commit cb8bce6

Browse files
committed
Guard SSH PTY resize dimensions
1 parent ae5b75a commit cb8bce6

6 files changed

Lines changed: 163 additions & 9 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
module: SSH Gateway
3+
date: 2026-03-09
4+
problem_type: runtime_error
5+
component: tooling
6+
symptoms:
7+
- "The terminal client crashed only when connected through `ssh pxpx.sh`."
8+
- "Resizing the SSH terminal could terminate the session with a renderer resize error."
9+
root_cause: async_timing
10+
resolution_type: code_fix
11+
severity: high
12+
tags: [ssh-gateway, pty, window-change, terminal-resize, opentui]
13+
---
14+
15+
# SSH PTY Zero-Size Window Change
16+
17+
## Context
18+
The terminal client stayed stable locally, but users could crash the session when resizing an SSH-backed `pxpx.sh` terminal.
19+
20+
## Problem
21+
During SSH `window-change`, some terminals can briefly report invalid PTY dimensions such as `0x0`. The gateway forwarded those values to the child PTY runner, which then propagated them to the OpenTUI client. OpenTUI rejects framebuffer resizes at `0` width or height and aborts.
22+
23+
## Working Solution
24+
- Added `sanitizePtyWindow` and `sanitizePtyInfo` in `src/ssh-gateway/pty.ts`
25+
- Sanitized initial PTY dimensions before launching the interactive child
26+
- Sanitized every SSH `window-change` event before forwarding it
27+
- Preserved the last valid PTY size when a resize event reported invalid dimensions
28+
- Added regression tests in `tests/ssh-gateway/pty.test.ts`
29+
30+
## Key Insight
31+
This was not a generic client-side resize bug. The failure path was specific to the SSH PTY bridge, where transient invalid sizes can appear during interactive terminal resizes.
32+
33+
## Verification
34+
- Ran `bun test tests/ssh-gateway/*.test.ts`
35+
- Ran `pnpm typecheck`

src/ssh-gateway/child-process.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { resolveCommandPlan } from "./command-plan";
44
import type { GatewayConfig } from "./config";
55
import { formatGatewayError } from "./errors";
66
import { acquireControlChannel } from "./control-channel";
7+
import { sanitizePtyWindow } from "./pty";
78
import { buildRunnerEnvironment, buildSpawnOptions } from "./runner-env";
89
import type { AuthIdentity, CommandPlan, ExecutionHandle, PtyState, UserAccount } from "./types";
910
import type { PseudoTtyInfo, ServerChannel } from "ssh2";
@@ -88,15 +89,16 @@ export function launchInteractiveCommand(
8889
account: UserAccount,
8990
ptyInfo: PseudoTtyInfo,
9091
): ExecutionHandle {
92+
const window = sanitizePtyWindow(ptyInfo.rows, ptyInfo.cols);
9193
const env = buildRunnerEnvironment(config, identity, account, (ptyInfo as PtyState).term ?? "xterm-256color");
9294
const scope = Effect.runSync(Scope.make());
93-
const control = Effect.runSync(Scope.extend(acquireControlChannel(ptyInfo.rows, ptyInfo.cols), scope));
95+
const control = Effect.runSync(Scope.extend(acquireControlChannel(window.rows, window.cols), scope));
9496
const runnerArgs = [
9597
config.runner,
9698
"--rows",
97-
String(ptyInfo.rows),
99+
String(window.rows),
98100
"--cols",
99-
String(ptyInfo.cols),
101+
String(window.cols),
100102
"--cwd",
101103
config.workdir,
102104
"--uid",

src/ssh-gateway/control-channel.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import net from "node:net";
33
import os from "node:os";
44
import path from "node:path";
55
import { Effect } from "effect";
6+
import { sanitizePtyWindow } from "./pty";
67
import type { ControlChannel } from "./types";
78

89
export function createControlChannel(initialRows: number, initialCols: number): ControlChannel {
@@ -12,7 +13,8 @@ export function createControlChannel(initialRows: number, initialCols: number):
1213
);
1314
const server = net.createServer();
1415
let controlSocket: net.Socket | null = null;
15-
let pendingWindow = `${initialRows} ${initialCols}\n`;
16+
let currentWindow = sanitizePtyWindow(initialRows, initialCols);
17+
let pendingWindow = `${currentWindow.rows} ${currentWindow.cols}\n`;
1618

1719
server.on("connection", (socket) => {
1820
controlSocket = socket;
@@ -38,7 +40,8 @@ export function createControlChannel(initialRows: number, initialCols: number):
3840
rmSync(socketPath, { force: true });
3941
},
4042
updateWindow(rows, cols) {
41-
const payload = `${rows} ${cols}\n`;
43+
currentWindow = sanitizePtyWindow(rows, cols, currentWindow);
44+
const payload = `${currentWindow.rows} ${currentWindow.cols}\n`;
4245

4346
if (controlSocket) {
4447
controlSocket.write(payload);

src/ssh-gateway/pty.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { PseudoTtyInfo } from "ssh2";
2+
3+
const DEFAULT_PTY_ROWS = 24;
4+
const DEFAULT_PTY_COLS = 80;
5+
6+
function sanitizeDimension(value: number, fallback: number) {
7+
if (!Number.isFinite(value) || value <= 0) {
8+
return fallback;
9+
}
10+
11+
return Math.floor(value);
12+
}
13+
14+
export function sanitizePtyWindow(
15+
rows: number,
16+
cols: number,
17+
fallback?: {
18+
rows?: number;
19+
cols?: number;
20+
},
21+
) {
22+
const safeFallbackRows = sanitizeDimension(fallback?.rows ?? DEFAULT_PTY_ROWS, DEFAULT_PTY_ROWS);
23+
const safeFallbackCols = sanitizeDimension(fallback?.cols ?? DEFAULT_PTY_COLS, DEFAULT_PTY_COLS);
24+
25+
return {
26+
rows: sanitizeDimension(rows, safeFallbackRows),
27+
cols: sanitizeDimension(cols, safeFallbackCols),
28+
};
29+
}
30+
31+
export function sanitizePtyInfo(info: PseudoTtyInfo, fallback?: Partial<PseudoTtyInfo>): PseudoTtyInfo {
32+
const window = sanitizePtyWindow(info.rows, info.cols, {
33+
rows: fallback?.rows,
34+
cols: fallback?.cols,
35+
});
36+
37+
return {
38+
...(fallback ?? {}),
39+
...info,
40+
rows: window.rows,
41+
cols: window.cols,
42+
};
43+
}

src/ssh-gateway/session.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Connection, ExecInfo, PseudoTtyInfo } from "ssh2";
22
import { handleExecution } from "./child-process";
3+
import { sanitizePtyInfo } from "./pty";
34
import type { AuthIdentity, ExecutionHandle, UserAccount } from "./types";
45
import type { GatewayConfig } from "./config";
56

@@ -31,12 +32,13 @@ export function attachSessionHandlers(
3132
let execution: ExecutionHandle = NOOP_EXECUTION_HANDLE;
3233

3334
session.on("pty", (sessionAccept, _sessionReject, info) => {
34-
ptyInfo = info;
35+
ptyInfo = sanitizePtyInfo(info, ptyInfo ?? undefined);
3536
sessionAccept?.();
3637
});
3738

3839
session.on("window-change", (sessionAccept, _sessionReject, info) => {
39-
ptyInfo = {
40+
ptyInfo = sanitizePtyInfo(
41+
{
4042
...(ptyInfo ?? {
4143
term: "xterm-256color",
4244
modes: {},
@@ -49,8 +51,10 @@ export function attachSessionHandlers(
4951
height: info.height,
5052
rows: info.rows,
5153
cols: info.cols,
52-
};
53-
execution.updateWindow(info.rows, info.cols);
54+
},
55+
ptyInfo ?? undefined,
56+
);
57+
execution.updateWindow(ptyInfo.rows, ptyInfo.cols);
5458
sessionAccept?.();
5559
});
5660

tests/ssh-gateway/pty.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { sanitizePtyInfo, sanitizePtyWindow } from "../../src/ssh-gateway/pty";
3+
4+
describe("sanitizePtyWindow", () => {
5+
test("falls back to default dimensions for invalid initial sizes", () => {
6+
expect(sanitizePtyWindow(0, 0)).toEqual({
7+
rows: 24,
8+
cols: 80,
9+
});
10+
});
11+
12+
test("preserves the previous valid size when a resize update is invalid", () => {
13+
expect(
14+
sanitizePtyWindow(0, -4, {
15+
rows: 32,
16+
cols: 120,
17+
}),
18+
).toEqual({
19+
rows: 32,
20+
cols: 120,
21+
});
22+
});
23+
24+
test("keeps valid resize updates", () => {
25+
expect(
26+
sanitizePtyWindow(18, 90, {
27+
rows: 32,
28+
cols: 120,
29+
}),
30+
).toEqual({
31+
rows: 18,
32+
cols: 90,
33+
});
34+
});
35+
});
36+
37+
describe("sanitizePtyInfo", () => {
38+
test("keeps previous rows and cols while updating the rest of the PTY metadata", () => {
39+
expect(
40+
sanitizePtyInfo(
41+
{
42+
term: "xterm-256color",
43+
width: 1024,
44+
height: 640,
45+
rows: 0,
46+
cols: 0,
47+
modes: {},
48+
},
49+
{
50+
term: "screen-256color",
51+
width: 800,
52+
height: 600,
53+
rows: 28,
54+
cols: 100,
55+
modes: {},
56+
},
57+
),
58+
).toEqual({
59+
term: "xterm-256color",
60+
width: 1024,
61+
height: 640,
62+
rows: 28,
63+
cols: 100,
64+
modes: {},
65+
});
66+
});
67+
});

0 commit comments

Comments
 (0)