mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
fix(tui): clamp bogus terminal dimensions (WSL 131072x1) (#35657)
Some hosts (notably WSL) report a junk window size such as 131072 columns by 1 row. Both the Ink fork and our components only guard against 0/null/undefined/NaN (stdout.columns || 80), so a positive-but-absurd width sails through into createScreen(width*height), allocating tens to hundreds of MB per frame and tripping the TUI memory monitor's hard exit. Add clampStdoutDimensions(), installed in entry.tsx before ink.render: it patches process.stdout.columns/rows with clamping getters (cols 1-2000, rows 1-1000; out-of-range -> 80x24). One install point fixes the renderer, its resize handler, and every component read. Live resizes still propagate through the original descriptor, just clamped.
This commit is contained in:
parent
cd067ab91e
commit
b1d34cf6e2
3 changed files with 258 additions and 0 deletions
108
ui-tui/src/__tests__/terminalDimensions.test.ts
Normal file
108
ui-tui/src/__tests__/terminalDimensions.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clampStdoutDimensions,
|
||||
DEFAULT_COLUMNS,
|
||||
DEFAULT_ROWS,
|
||||
MAX_COLUMNS,
|
||||
MAX_ROWS,
|
||||
sanitizeDimension,
|
||||
sanitizeTerminalSize
|
||||
} from '../lib/terminalDimensions.js'
|
||||
|
||||
describe('sanitizeDimension', () => {
|
||||
it('passes through an in-range value', () => {
|
||||
expect(sanitizeDimension(120, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(120)
|
||||
})
|
||||
|
||||
it('floors fractional values', () => {
|
||||
expect(sanitizeDimension(80.9, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(80)
|
||||
})
|
||||
|
||||
it('clamps an absurd width to the max, not the fallback', () => {
|
||||
expect(sanitizeDimension(131072, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(MAX_COLUMNS)
|
||||
})
|
||||
|
||||
it('falls back when value is zero', () => {
|
||||
expect(sanitizeDimension(0, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
|
||||
})
|
||||
|
||||
it('falls back when value is negative', () => {
|
||||
expect(sanitizeDimension(-5, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
|
||||
})
|
||||
|
||||
it('falls back on NaN / undefined / non-number', () => {
|
||||
expect(sanitizeDimension(NaN, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
|
||||
expect(sanitizeDimension(undefined, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
|
||||
expect(sanitizeDimension('80', 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
|
||||
expect(sanitizeDimension(Infinity, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeTerminalSize', () => {
|
||||
it('sanitizes the WSL 131072x1 report', () => {
|
||||
// 131072 cols is absurd → clamp to max; 1 row is a valid (degenerate) TTY → keep.
|
||||
expect(sanitizeTerminalSize(131072, 1)).toEqual({ columns: MAX_COLUMNS, rows: 1 })
|
||||
})
|
||||
|
||||
it('passes a normal terminal through unchanged', () => {
|
||||
expect(sanitizeTerminalSize(120, 40)).toEqual({ columns: 120, rows: 40 })
|
||||
})
|
||||
|
||||
it('falls back when both dimensions are missing', () => {
|
||||
expect(sanitizeTerminalSize(undefined, undefined)).toEqual({
|
||||
columns: DEFAULT_COLUMNS,
|
||||
rows: DEFAULT_ROWS
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps an oversized height', () => {
|
||||
expect(sanitizeTerminalSize(80, 99999)).toEqual({ columns: 80, rows: MAX_ROWS })
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampStdoutDimensions', () => {
|
||||
it('clamps a bogus columns getter on a live stream', () => {
|
||||
let raw = 131072
|
||||
const stream: { columns?: number; rows?: number } = {}
|
||||
Object.defineProperty(stream, 'columns', { configurable: true, get: () => raw })
|
||||
Object.defineProperty(stream, 'rows', { configurable: true, get: () => 1 })
|
||||
|
||||
clampStdoutDimensions(stream)
|
||||
|
||||
expect(stream.columns).toBe(MAX_COLUMNS)
|
||||
expect(stream.rows).toBe(1)
|
||||
|
||||
// Live resize still propagates through the original getter, clamped.
|
||||
raw = 100
|
||||
expect(stream.columns).toBe(100)
|
||||
|
||||
raw = 0
|
||||
expect(stream.columns).toBe(DEFAULT_COLUMNS)
|
||||
})
|
||||
|
||||
it('clamps a bogus plain-value columns property', () => {
|
||||
const stream: { columns?: number; rows?: number } = { columns: 131072, rows: 24 }
|
||||
|
||||
clampStdoutDimensions(stream)
|
||||
|
||||
expect(stream.columns).toBe(MAX_COLUMNS)
|
||||
expect(stream.rows).toBe(24)
|
||||
})
|
||||
|
||||
it('is idempotent', () => {
|
||||
const stream: { columns?: number; rows?: number } = { columns: 131072, rows: 24 }
|
||||
|
||||
clampStdoutDimensions(stream)
|
||||
clampStdoutDimensions(stream)
|
||||
|
||||
expect(stream.columns).toBe(MAX_COLUMNS)
|
||||
})
|
||||
|
||||
it('does not crash on a non-configurable property', () => {
|
||||
const stream: { columns?: number; rows?: number } = {}
|
||||
Object.defineProperty(stream, 'columns', { configurable: false, value: 131072 })
|
||||
|
||||
expect(() => clampStdoutDimensions(stream)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -11,6 +11,7 @@ import { setupGracefulExit } from './lib/gracefulExit.js'
|
|||
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
|
||||
import { type MemorySnapshot, startMemoryMonitor } from './lib/memoryMonitor.js'
|
||||
import { openExternalUrl } from './lib/openExternalUrl.js'
|
||||
import { clampStdoutDimensions } from './lib/terminalDimensions.js'
|
||||
import { resetTerminalModes } from './lib/terminalModes.js'
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
|
|
@ -18,6 +19,12 @@ if (!process.stdin.isTTY) {
|
|||
process.exit(0)
|
||||
}
|
||||
|
||||
// Some hosts (notably WSL) report bogus window sizes such as 131072x1. Clamp
|
||||
// `process.stdout.columns`/`rows` at the source so the Ink renderer, its
|
||||
// resize handler, and every component read see sane values. Must run before
|
||||
// `ink.render` constructs the renderer.
|
||||
clampStdoutDimensions()
|
||||
|
||||
// Start from a clean slate. If a previous TUI crashed or was kill -9'd, the
|
||||
// terminal tab can still have mouse/focus/paste modes enabled.
|
||||
resetTerminalModes()
|
||||
|
|
|
|||
143
ui-tui/src/lib/terminalDimensions.ts
Normal file
143
ui-tui/src/lib/terminalDimensions.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Sanitize terminal dimensions reported by the host.
|
||||
*
|
||||
* Some environments report bogus window sizes. The motivating case (WSL,
|
||||
* reported by @northframe_17) is `columns=131072, rows=1` — a width that
|
||||
* overflows any sane layout and a height of one row that makes the TUI
|
||||
* unusable. Node's own `stdout.columns || 80` fallback only catches
|
||||
* `0`/`NaN`/`undefined`, so a positive-but-absurd value sails straight into
|
||||
* the Ink renderer, which then allocates a 131072-cell-wide screen buffer.
|
||||
*
|
||||
* We clamp each dimension independently to a sane range. Out-of-range or
|
||||
* non-finite values fall back to the conventional 80x24 default rather than
|
||||
* the raw garbage.
|
||||
*/
|
||||
|
||||
export const DEFAULT_COLUMNS = 80
|
||||
export const DEFAULT_ROWS = 24
|
||||
|
||||
// Upper bounds are generous (ultrawide multi-monitor terminals, tmux panes
|
||||
// spanning huge displays) but well below the WSL garbage value. Anything
|
||||
// beyond these is treated as a broken probe.
|
||||
export const MAX_COLUMNS = 2000
|
||||
export const MAX_ROWS = 1000
|
||||
export const MIN_COLUMNS = 1
|
||||
export const MIN_ROWS = 1
|
||||
|
||||
/**
|
||||
* Clamp a single reported dimension into `[min, max]`.
|
||||
*
|
||||
* Returns `fallback` when the value is non-finite or `<= 0` (the classic
|
||||
* "no size yet" signal). A positive value above `max` is clamped to `max`,
|
||||
* not replaced by the fallback — an oversized-but-finite report is more
|
||||
* likely a real-but-large terminal than a missing one, and clamping keeps
|
||||
* the layout sane either way.
|
||||
*/
|
||||
export function sanitizeDimension(value: unknown, min: number, max: number, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const rounded = Math.floor(value)
|
||||
|
||||
if (rounded < min) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if (rounded > max) {
|
||||
return max
|
||||
}
|
||||
|
||||
return rounded
|
||||
}
|
||||
|
||||
export interface SanitizedTerminalSize {
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
/** Sanitize a (columns, rows) pair using the TUI's bounds. */
|
||||
export function sanitizeTerminalSize(columns: unknown, rows: unknown): SanitizedTerminalSize {
|
||||
return {
|
||||
columns: sanitizeDimension(columns, MIN_COLUMNS, MAX_COLUMNS, DEFAULT_COLUMNS),
|
||||
rows: sanitizeDimension(rows, MIN_ROWS, MAX_ROWS, DEFAULT_ROWS)
|
||||
}
|
||||
}
|
||||
|
||||
interface ClampableStream {
|
||||
columns?: number
|
||||
rows?: number
|
||||
}
|
||||
|
||||
const PATCHED = Symbol.for('hermes.tui.clampedDimensions')
|
||||
|
||||
/**
|
||||
* Install clamping getters on `process.stdout` (or a provided stream) so every
|
||||
* downstream reader — the Ink renderer's root layout, its `resize` handler,
|
||||
* and our React components' `stdout.columns ?? 80` reads — sees sanitized
|
||||
* values. Must run before `ink.render`.
|
||||
*
|
||||
* Idempotent: re-installing on an already-patched stream is a no-op. The raw
|
||||
* values are read through the original property descriptor on each access, so
|
||||
* live resizes still propagate (just clamped).
|
||||
*/
|
||||
export function clampStdoutDimensions(stream: ClampableStream = process.stdout): void {
|
||||
const target = stream as ClampableStream & { [PATCHED]?: boolean }
|
||||
|
||||
if (target[PATCHED]) {
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the original descriptors so we read the live host value on every
|
||||
// access rather than freezing a single snapshot.
|
||||
const colsDesc = findDescriptor(target, 'columns')
|
||||
const rowsDesc = findDescriptor(target, 'rows')
|
||||
|
||||
const readCols = () => (colsDesc ? readValue(target, colsDesc) : target.columns)
|
||||
const readRows = () => (rowsDesc ? readValue(target, rowsDesc) : target.rows)
|
||||
|
||||
try {
|
||||
Object.defineProperty(target, 'columns', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return sanitizeDimension(readCols(), MIN_COLUMNS, MAX_COLUMNS, DEFAULT_COLUMNS)
|
||||
}
|
||||
})
|
||||
Object.defineProperty(target, 'rows', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return sanitizeDimension(readRows(), MIN_ROWS, MAX_ROWS, DEFAULT_ROWS)
|
||||
}
|
||||
})
|
||||
target[PATCHED] = true
|
||||
} catch {
|
||||
// Non-configurable property on an exotic stream — leave it alone rather
|
||||
// than crashing startup. Components still have their own `?? 80` guard.
|
||||
}
|
||||
}
|
||||
|
||||
function findDescriptor(obj: object, key: string): PropertyDescriptor | undefined {
|
||||
let cur: object | null = obj
|
||||
|
||||
while (cur) {
|
||||
const desc = Object.getOwnPropertyDescriptor(cur, key)
|
||||
|
||||
if (desc) {
|
||||
return desc
|
||||
}
|
||||
|
||||
cur = Object.getPrototypeOf(cur) as object | null
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readValue(target: object, desc: PropertyDescriptor): unknown {
|
||||
if (desc.get) {
|
||||
return desc.get.call(target)
|
||||
}
|
||||
|
||||
return desc.value
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue