Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
84 changes: 84 additions & 0 deletions src/utils/__tests__/terminal-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
import type { NativeProbeDeps } from '../terminal-native';
import {
parsePpidFromStat,
parseTtyNrFromStat,
probeTerminalNative,
probeWidthNative
} from '../terminal-native';

Expand Down Expand Up @@ -37,6 +39,18 @@ describe('parsePpidFromStat', () => {
});
});

describe('parseTtyNrFromStat', () => {
it('parses tty_nr when comm contains spaces and parens', () => {
expect(parseTtyNrFromStat('4242 (my ) weird proc) S 1234 4242 4242 34818 -1 0')).toBe(34818);
expect(parseTtyNrFromStat(TRICKY_STAT)).toBe(0);
});

it('returns null on garbage', () => {
expect(parseTtyNrFromStat('not a stat line')).toBeNull();
expect(parseTtyNrFromStat('1 (node) S 2')).toBeNull();
});
});

describe('probeWidthNative', () => {
it('returns null on non-linux platforms', () => {
expect(probeWidthNative(makeDeps({ platform: 'darwin' }))).toBeNull();
Expand Down Expand Up @@ -105,3 +119,73 @@ describe('probeWidthNative', () => {
expect(closed).toEqual([7]);
});
});

describe('probeTerminalNative', () => {
// Stat line for `pid` with parent `ppid` and controlling terminal `ttyNr`.
const stat = (pid: number, ppid: number, ttyNr = 0): string => `${pid} (proc) S ${ppid} ${pid} ${pid} ${ttyNr} -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0`;

// self -> 300 -> 200 -> 1, no fd of any ancestor points at a tty.
function chainDeps(ttyNrs: Record<number, number> = {}, unreadable: number[] = []): NativeProbeDeps {
const parents: Record<number, number> = { [process.pid]: 300, 300: 200, 200: 1, 1: 0 };
return makeDeps({
readFileSync: (p: string) => {
const pid = Number(/^\/proc\/(\d+)\/stat$/.exec(p)?.[1]);
const ppid = parents[pid];
if (ppid === undefined || unreadable.includes(pid)) {
throw new Error(`EACCES: ${p}`);
}

return stat(pid, ppid, ttyNrs[pid] ?? 0);
},
readlinkSync: () => '/dev/null'
});
}

it('is conclusive when no ancestor (including pid 1) has a controlling terminal', () => {
expect(probeTerminalNative(chainDeps())).toEqual({ width: null, noControllingTTY: true });
});

it('is inconclusive when an ancestor has a controlling terminal but no tty on its stdio', () => {
// e.g. stdio redirected away from the terminal: ps -o tty= still reports it.
expect(probeTerminalNative(chainDeps({ 200: 34818 }))).toEqual({ width: null, noControllingTTY: false });
});

it('is inconclusive when pid 1 has a controlling terminal', () => {
// The ps walk queries pid 1's tty too (e.g. an interactive shell as a container's init).
expect(probeTerminalNative(chainDeps({ 1: 34816 }))).toEqual({ width: null, noControllingTTY: false });
});

it('is inconclusive when an ancestor stat cannot be read', () => {
expect(probeTerminalNative(chainDeps({}, [200])).noControllingTTY).toBe(false);
expect(probeTerminalNative(chainDeps({}, [1])).noControllingTTY).toBe(false);
});

it('ignores our own controlling terminal, which the ps walk never checks', () => {
expect(probeTerminalNative(chainDeps({ [process.pid]: 34818 })).noControllingTTY).toBe(true);
});

it('checks the last ancestor when the depth limit is reached', () => {
// An endless chain: self -> 1000 -> 1001 -> ... The 8th ancestor is 1007.
const deps = (lastTtyNr: number) => makeDeps({
readFileSync: (p: string) => {
const pid = Number(/^\/proc\/(\d+)\/stat$/.exec(p)?.[1]);
const next = pid === process.pid ? 1000 : pid + 1;
return stat(pid, next, pid === 1007 ? lastTtyNr : 0);
},
readlinkSync: () => '/dev/null'
});

expect(probeTerminalNative(deps(0)).noControllingTTY).toBe(true);
expect(probeTerminalNative(deps(34818)).noControllingTTY).toBe(false);
});

it('is inconclusive on non-linux platforms', () => {
expect(probeTerminalNative(makeDeps({ platform: 'darwin' }))).toEqual({ width: null, noControllingTTY: false });
});

it('reports a found width', () => {
const deps = chainDeps({ 200: 34818 });
expect(probeTerminalNative({ ...deps, readlinkSync: (p: string) => (p === '/proc/200/fd/1' ? '/dev/pts/2' : '/dev/null') }))
.toEqual({ width: 209, noControllingTTY: false });
});
});
50 changes: 50 additions & 0 deletions src/utils/__tests__/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getTerminalWidth,
resetTerminalWidthCache
} from '../terminal';
import * as terminalNative from '../terminal-native';
import * as terminalWidthCache from '../terminal-width-cache';

vi.mock('child_process', () => ({
Expand Down Expand Up @@ -350,6 +351,55 @@ describe('terminal utils', () => {
expect(getTerminalWidth()).toBe(175);
});

describe('native /proc probe result', () => {
it('skips the ps/stty walk but keeps tput when /proc shows no ancestor has a controlling terminal', () => {
setPlatform('linux');
vi.spyOn(terminalNative, 'probeTerminalNative').mockReturnValue({ width: null, noControllingTTY: true });
mockExecFileSync.mockImplementation((file: string) => {
if (file === 'tput') {
return '80\n';
}

throw new Error(`Unexpected command: ${file}`);
});

expect(getTerminalWidth()).toBe(80);
expect(mockExecFileSync.mock.calls.map(([file, args]) => `${file as string} ${(args as string[]).join(' ')}`)).toEqual([
'tput cols'
]);
});

it('still walks ancestors with ps when the /proc probe is inconclusive', () => {
setPlatform('linux');
vi.spyOn(terminalNative, 'probeTerminalNative').mockReturnValue({ width: null, noControllingTTY: false });
mockExecFileSync.mockImplementation((file: string, args: string[]) => {
if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) {
return '1234\n';
}

if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') {
return 'pts/3\n';
}

if (file === 'stty' && args.join(' ') === '-F /dev/pts/3 size') {
return '24 132\n';
}

throw new Error(`Unexpected command: ${file} ${args.join(' ')}`);
});

expect(getTerminalWidth()).toBe(132);
});

it('returns the native width without spawning anything', () => {
setPlatform('linux');
vi.spyOn(terminalNative, 'probeTerminalNative').mockReturnValue({ width: 209, noControllingTTY: false });

expect(getTerminalWidth()).toBe(209);
expect(mockExecFileSync).not.toHaveBeenCalled();
});
});

// Wiring coverage for the sessionId/ttlSeconds L2-cache integration: unlike
// the tests above (which never pass options), these mock ../terminal-width-cache
// directly and assert on the exact arguments getTerminalWidth calls it with.
Expand Down
84 changes: 71 additions & 13 deletions src/utils/terminal-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ export function parsePpidFromStat(stat: string): number | null {
return ppid;
}

/**
* Parse tty_nr (field 7) out of a /proc/<pid>/stat line: the controlling
* terminal's device number, 0 when the process has none. This is the value
* `ps -o tty=` renders, printing "?" for 0.
*/
export function parseTtyNrFromStat(stat: string): number | null {
const commEnd = stat.lastIndexOf(')');
if (commEnd === -1) {
return null;
}

// After "(comm)": state, ppid, pgrp, session, tty_nr, ...
const fields = stat.slice(commEnd + 1).trim().split(/\s+/);
const ttyNr = parseInt(fields[4] ?? '', 10);
return isNaN(ttyNr) ? null : ttyNr;
}

function readStat(pid: number, deps: NativeProbeDeps): string | null {
try {
return deps.readFileSync(`/proc/${pid}/stat`);
} catch {
return null;
}
}

function hasNoControllingTTY(pid: number, deps: NativeProbeDeps): boolean {
const stat = readStat(pid, deps);
return stat !== null && parseTtyNrFromStat(stat) === 0;
}

function findTTYDevice(pid: number, deps: NativeProbeDeps): string | null {
for (const fd of STDIO_FDS) {
try {
Expand Down Expand Up @@ -92,28 +122,50 @@ function widthOfDevice(device: string, deps: NativeProbeDeps): number | null {
}
}

export interface NativeProbeResult {
width: number | null;
/**
* True only when the walk read /proc/<pid>/stat for every ancestor the
* portable `ps` walk would visit (up to MAX_ANCESTOR_DEPTH, including pid 1)
* and none of them has a controlling terminal. `ps -o tty=` would then print
* "?" for each of them and no `stty` would ever run, so that walk can be
* skipped without changing the result.
*/
noControllingTTY: boolean;
}

/**
* Probe terminal width with zero subprocesses, using /proc and TIOCGWINSZ.
* Linux only; returns null anywhere else so the caller falls back to the
* portable ps/stty/tput path.
* Linux only; anywhere else the result is inconclusive so the caller falls
* back to the portable ps/stty/tput path.
*/
export function probeWidthNative(deps: NativeProbeDeps = defaultDeps): number | null {
export function probeTerminalNative(deps: NativeProbeDeps = defaultDeps): NativeProbeResult {
const inconclusive: NativeProbeResult = { width: null, noControllingTTY: false };
if (deps.platform !== 'linux') {
return null;
return inconclusive;
}

let sawControllingTTY = false;
let pid = process.pid;
for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth += 1) {
let stat: string;
try {
stat = deps.readFileSync(`/proc/${pid}/stat`);
} catch {
return null;
const stat = readStat(pid, deps);
if (stat === null) {
return inconclusive;
}

// `pid` is the ancestor visited in the previous iteration (not ourselves).
if (depth > 0 && parseTtyNrFromStat(stat) !== 0) {
sawControllingTTY = true;
}

const parentPid = parsePpidFromStat(stat);
if (parentPid === null || parentPid <= 1) {
return null;
if (parentPid === null) {
return inconclusive;
}

if (parentPid <= 1) {
// The ps walk stops here too, after also querying pid 1's tty.
return { width: null, noControllingTTY: !sawControllingTTY && hasNoControllingTTY(1, deps) };
}

pid = parentPid;
Expand All @@ -125,9 +177,15 @@ export function probeWidthNative(deps: NativeProbeDeps = defaultDeps): number |

const width = widthOfDevice(device, deps);
if (width !== null) {
return width;
return { width, noControllingTTY: false };
}
}

return null;
// Depth limit reached: the last ancestor visited has not had its stat read yet.
return { width: null, noControllingTTY: !sawControllingTTY && hasNoControllingTTY(pid, deps) };
}

/** Width-only view of probeTerminalNative. */
export function probeWidthNative(deps: NativeProbeDeps = defaultDeps): number | null {
return probeTerminalNative(deps).width;
}
52 changes: 33 additions & 19 deletions src/utils/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

import { probeWidthNative } from './terminal-native';
import { probeTerminalNative } from './terminal-native';
import {
readCachedWidth,
writeCachedWidth
Expand Down Expand Up @@ -45,13 +45,40 @@ function probeTerminalWidth(): number | null {
return null;
}

// Zero-subprocess path (Linux): /proc ancestry + TIOCGWINSZ. Returns null on
// other platforms and falls through to the portable ps/stty/tput walk below.
const nativeWidth = probeWidthNative();
if (nativeWidth !== null) {
return nativeWidth;
// Zero-subprocess path (Linux): /proc ancestry + TIOCGWINSZ. Inconclusive
// on other platforms and falls through to the portable ps/stty/tput walk below.
const native = probeTerminalNative();
if (native.width !== null) {
return native.width;
}

// When /proc already showed that no ancestor has a controlling terminal,
// `ps -o tty=` would print "?" for every one of them, so the ps walk (two
// spawns per ancestor, on every render) cannot find a width. Skip it.
if (!native.noControllingTTY) {
const width = probeAncestorWidth();
if (width !== null) {
return width;
}
}

// Fallback: try tput cols which might work in some environments
try {
const width = execFileSync('tput', ['cols'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
windowsHide: true
}).trim();

return parsePositiveInteger(width);
} catch {
// tput also failed
}

return null;
}

function probeAncestorWidth(): number | null {
// Claude Code can spawn ccstatusline with piped stdio, leaving the immediate
// parent process without a controlling TTY. Walk up a few ancestors until we
// find the shell process that owns the real PTY.
Expand All @@ -75,19 +102,6 @@ function probeTerminalWidth(): number | null {
}
}

// Fallback: try tput cols which might work in some environments
try {
const width = execFileSync('tput', ['cols'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
windowsHide: true
}).trim();

return parsePositiveInteger(width);
} catch {
// tput also failed
}

return null;
}

Expand Down
Loading