Skip to content
Closed
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
134 changes: 134 additions & 0 deletions packages/core/src/services/shellExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const mockIsBinary = vi.hoisted(() => vi.fn());
const mockPlatform = vi.hoisted(() => vi.fn());
const mockHomedir = vi.hoisted(() => vi.fn());
const mockMkdirSync = vi.hoisted(() => vi.fn());
const mockReadFileSync = vi.hoisted(() => vi.fn());
const mockCreateWriteStream = vi.hoisted(() => vi.fn());
const mockGetPty = vi.hoisted(() => vi.fn());
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
Expand All @@ -44,6 +45,10 @@ const mockDebugLogger = vi.hoisted(() => ({
debug: vi.fn(),
}));

const mockReadFileSyncObj = vi.hoisted(() => ({
original: null as typeof import('node:fs').readFileSync | null,
}));

// Top-level Mocks
vi.mock('../config/storage.js', () => ({
Storage: {
Expand All @@ -58,15 +63,19 @@ vi.mock('@lydell/node-pty', () => ({
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
mockReadFileSyncObj.original = actual.readFileSync;
mockReadFileSync.mockImplementation(actual.readFileSync);
return {
...actual,
default: {
...actual,
mkdirSync: mockMkdirSync,
createWriteStream: mockCreateWriteStream,
readFileSync: mockReadFileSync,
},
mkdirSync: mockMkdirSync,
createWriteStream: mockCreateWriteStream,
readFileSync: mockReadFileSync,
};
});
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
Expand Down Expand Up @@ -1814,6 +1823,131 @@ describe('ShellExecutionService execution method selection', () => {
expect(mockCpSpawn).toHaveBeenCalled();
expect(result.executionMethod).toBe('child_process');
});

it('should bypass node-pty and use child_process on WSL when executing a command with .exe', async () => {
mockPlatform.mockReturnValue('linux');
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu');
mockSerializeTerminalToObject.mockReturnValue([]);

const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'adb.exe devices',
'/test/dir',
onOutputEventMock,
abortController.signal,
true, // shouldUseNodePty
shellExecutionConfig,
);

// Simulate exit of child_process fallback
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
const result = await handle.result;

expect(mockPtySpawn).not.toHaveBeenCalled();
expect(mockCpSpawn).toHaveBeenCalled();
expect(result.executionMethod).toBe('child_process');

vi.unstubAllEnvs();
});

it('should use node-pty on WSL when executing a non-.exe command', async () => {
mockPlatform.mockReturnValue('linux');
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu');
mockSerializeTerminalToObject.mockReturnValue([]);

const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'ls -la',
'/test/dir',
onOutputEventMock,
abortController.signal,
true, // shouldUseNodePty
shellExecutionConfig,
);

if (!mockPtyProcess.onExit.mock.calls[0]) {
const res = await handle.result;
throw new Error(`Failed early in executeWithPty: ${res.error}`);
}
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
const result = await handle.result;

expect(mockPtySpawn).toHaveBeenCalled();
expect(mockCpSpawn).not.toHaveBeenCalled();
expect(result.executionMethod).toBe('mock-pty');

vi.unstubAllEnvs();
});

it('should use node-pty on WSL when executing a command with .exe only in arguments', async () => {
mockPlatform.mockReturnValue('linux');
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu');
mockSerializeTerminalToObject.mockReturnValue([]);

const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'vim app.exe',
'/test/dir',
onOutputEventMock,
abortController.signal,
true, // shouldUseNodePty
shellExecutionConfig,
);

if (!mockPtyProcess.onExit.mock.calls[0]) {
const res = await handle.result;
throw new Error(`Failed early in executeWithPty: ${res.error}`);
}
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
const result = await handle.result;

expect(mockPtySpawn).toHaveBeenCalled();
expect(mockCpSpawn).not.toHaveBeenCalled();
expect(result.executionMethod).toBe('mock-pty');

vi.unstubAllEnvs();
});

it('should use node-pty on standard Linux when executing a command with .exe', async () => {
mockPlatform.mockReturnValue('linux');
vi.stubEnv('WSL_DISTRO_NAME', ''); // No WSL
vi.stubEnv('WSLENV', '');
vi.stubEnv('WSL_INTEROP', '');
mockSerializeTerminalToObject.mockReturnValue([]);

// Mock readFileSync to simulate a standard (non-microsoft/WSL) kernel version
mockReadFileSync.mockImplementation((path, options) => {
if (typeof path === 'string' && path.includes('/proc/version')) {
return 'Linux version 5.4.0-generic (buildd@lgw01-amd64-060)';
}
return mockReadFileSyncObj.original!(path, options);
});

const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'some_program.exe --version',
'/test/dir',
onOutputEventMock,
abortController.signal,
true, // shouldUseNodePty
shellExecutionConfig,
);

if (!mockPtyProcess.onExit.mock.calls[0]) {
const res = await handle.result;
throw new Error(`Failed early in executeWithPty: ${res.error}`);
}
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
const result = await handle.result;

expect(mockPtySpawn).toHaveBeenCalled();
expect(mockCpSpawn).not.toHaveBeenCalled();
expect(result.executionMethod).toBe('mock-pty');

mockReadFileSync.mockImplementation(mockReadFileSyncObj.original!);
vi.unstubAllEnvs();
});
});

describe('ShellExecutionService environment variables', () => {
Expand Down
38 changes: 36 additions & 2 deletions packages/core/src/services/shellExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
resolveExecutable,
type ShellType,
BASH_HUP_GUARD,
getCommandRoots,
initializeShellParsers,
} from '../utils/shell-utils.js';
import { isBinary, truncateString } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
Expand Down Expand Up @@ -386,7 +388,39 @@ export class ShellExecutionService {
shouldUseNodePty: boolean,
shellExecutionConfig: ShellExecutionConfig,
): Promise<ShellExecutionHandle> {
if (shouldUseNodePty) {
let finalShouldUseNodePty = shouldUseNodePty;

// Detect if we are on WSL and running a Windows executable (.exe).
// WSL has known terminal/PTY interop issues when running Windows binaries within a Linux PTY (like node-pty),
// which can lead to hangs, lost/missing output, or indefinite waiting for process exit.
if (finalShouldUseNodePty && os.platform() === 'linux') {
const isWSL =
Boolean(
process.env['WSL_DISTRO_NAME'] ||
process.env['WSLENV'] ||
process.env['WSL_INTEROP'],
) ||
(() => {
try {
return fs
.readFileSync('/proc/version', 'utf8')
.toLowerCase()
.includes('microsoft');
} catch {
return false;
}
})();

if (isWSL) {
await initializeShellParsers();
const commands = getCommandRoots(commandToExecute);
if (commands.some((cmd) => cmd.toLowerCase().endsWith('.exe'))) {
finalShouldUseNodePty = false;
}
}
Comment thread
Shra1V32 marked this conversation as resolved.
}

if (finalShouldUseNodePty) {
const ptyInfo = await getPty();
if (ptyInfo) {
try {
Expand All @@ -410,7 +444,7 @@ export class ShellExecutionService {
onOutputEvent,
abortSignal,
shellExecutionConfig,
shouldUseNodePty,
finalShouldUseNodePty,
);
}

Expand Down