Skip to content
Closed
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
198 changes: 122 additions & 76 deletions src/unixTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path';
import * as tty from 'tty';
import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from './terminal';
import { IProcessEnv, IPtyForkOptions, IPtyOpenOptions } from './interfaces';
import { ArgvOrCommandLine } from './types';
Expand All @@ -23,6 +21,123 @@ const DEFAULT_FILE = 'sh';
const DEFAULT_NAME = 'xterm';
const DESTROY_SOCKET_TIMEOUT_MS = 200;

type SocketConstructor = new (fd: number) => net.Socket;

// libuv (and by extension node.js) have a limitation where they check the type
// handle and if it's a TTY one assume that it must be a TTY client (not host).
// Because of this, they set UV_HANDLE_BLOCKING_WRITES on the libuv stream.
// This in turn means that on EAGAIN, it'll retry the write synchronously.
// This can cause deadlocks under macOS when its PTY pipe is full.
//
// To fix this, we use a hack to create a custom uv_pipe_t handle using pipe_wrap.
// If that fails, we fall back to tty.ReadStream but with a custom write function.
// The fallback is not ideal, because without poll() we can only use setTimeout.
//
// eslint-disable-next-line @typescript-eslint/naming-convention
const Socket: SocketConstructor = (() => {
try {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { Pipe, constants: PipeConstants } = (process as any).binding('pipe_wrap');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original version did use pipe_wrap 3913479, however the comment about deprecated api made the transition to the bug prone tty.ReadStream usage for write.

It should be fine now with the fallback we have in place and also the deprecated comment is not correct https://github.com/nodejs/node/blob/05f8772096f974190b11eabce0ea657bc5c8c1b1/lib/internal/bootstrap/realm.js#L84-L163

Copy link
Contributor

@deepak1556 deepak1556 Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand, we could implement the pipe_wrap equivalent version using uv_write which does the polling for us and also remove the fallback.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original version did use pipe_wrap 3913479, however the comment about deprecated api made the transition to the bug prone tty.ReadStream usage for write.

I knew about pty.js but didn't realize you also used to use this approach!

On the other hand, we could implement the pipe_wrap equivalent version using uv_write which does the polling for us and also remove the fallback.

I have also considered doing this, and possibly go even a step further and not use libuv at all (synchronous PTY calls should be a little bit faster). But @Tyriar suggested that ideally, we'd use as little C/C++ as possible in this project. Reading through node.js didn't give me the impression that process.binding or pipe_wrap are expected to go away any time soon. Perhaps I misjudged that, however. Essentially, we'd simply copy the pipe_wrap code, wouldn't we? I could do that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading through node.js didn't give me the impression that process.binding or pipe_wrap are expected to go away any time soon. Perhaps I misjudged that, however

You are right, there was some confusion around this. But my latest take based on nodejs/TSC#1482 (comment) and the redirection to internal binding https://github.com/nodejs/node/blob/05f8772096f974190b11eabce0ea657bc5c8c1b1/lib/internal/bootstrap/realm.js#L84-L163 this is not going away anytime soon.

I have also considered doing this, and possibly go even a step further and not use libuv at all (synchronous PTY calls should be a little bit faster)

It is also the approach currently being implemented in libuv/libuv#4802. The reason we started non-blocking mode is for the PTY host in VS Code to remain responsive to multiple terminal sessions and other process interactions (extension host, workbench), seems like a deal breaker ?

Essentially, we'd simply copy the pipe_wrap code, wouldn't we? I could do that.

Right, I was thinking we replace the write part with native uv_write and the read part can be based off tty.ReadStream. However if we bring in the buffer handling pieces it would almost be equivalent to write implementation in pipe_wrap. Maybe we just go back to only having the current pipe_wrap code in this PR (remove the fallback) till either

  1. process.binding('pipe_wrap') gets deprecated, in which case we can fork and implement our own version
  2. this modules becomes obsolete in favor of the builtin pty implementation from libuv

@Tyriar thoughts ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If my reading of fs.write is correct and it does return a byte offset, this would also fix a bug in the current code where it uses slice() on the raw string = codepoint offset.

This was a bug with the implementation I was aware of, I just wasn't hitting it in my ascii test as the codepoint offset is the byte offset in that case. #835 looks good but I suspect we still need to handle EAGAIN. Will look at that.

Maybe we just go back to only having the current pipe_wrap code in this PR (remove the fallback) till either

@deepak1556 this would be a hack that you'd have to maintain as monkey patching could break at any time and you're the one that daals with the node updates. Given what we've learned the last week, it's up to you how you'd like to handle it considering:

  • The more C++ the harder it is for us to maintain/spot bugs by eyeballing/etc. It's a lot more feasible now though with LLMs to help though.
  • Monkey patching is terrible and could break at any time. Forking isn't a bad option, but doesn't that mean 200 loc from https://github.com/nodejs/node/blob/main/src/pipe_wrap.cc plus some from deps too? I don't have faith in our ability to maintain that properly.
  • If it makes sense to upstream changes to node we could try that too?

const SOCKET = PipeConstants.SOCKET;

if (typeof Pipe === 'function' && typeof SOCKET === 'number') {
return class PipeSocket extends net.Socket {
constructor(fd: number) {
if (fd >> 0 !== fd || fd < 0) {
throw new Error(`Invalid file descriptor: ${fd}`);
}

const handle = new Pipe(SOCKET);
handle.open(fd);

super(<any>{
handle,
manualStart: true
});
}
};
}
} catch (e) {
}

console.warn('node-pty: falling back to tty.ReadStream');

const fs: typeof import('fs') = require('fs');
const tty: typeof import('tty') = require('tty');

interface IWriteTask {
data: Buffer;
offset: number;
}

return class TtySocket extends tty.ReadStream {
private readonly _fd: number;
private _encoding?: BufferEncoding = undefined;
private _writeQueue: IWriteTask[] = [];
private _timeout: NodeJS.Timeout | undefined;

constructor(fd: number) {
super(fd);
this._fd = fd;
}

// eslint-disable-next-line @typescript-eslint/naming-convention
public _destroy(error: Error | null, callback: (error: Error | null) => void): void {
if (this._timeout !== undefined) {
clearTimeout(this._timeout);
this._timeout = undefined;
}
return super._destroy(error, callback);
}

public setEncoding(encoding: BufferEncoding): this {
this._encoding = encoding;
return super.setEncoding(encoding);
}

public write(str: string | Buffer): boolean {
const data = typeof str === 'string'
? Buffer.from(str, this._encoding)
: Buffer.from(str);

if (data.byteLength !== 0) {
this._writeQueue.push({ data, offset: 0 });
if (this._writeQueue.length === 1) {
this._processQueue();
}
}

return true;
}

private _processQueue(): void {
if (this._writeQueue.length === 0) {
return;
}

const task = this._writeQueue[0];
fs.write(this._fd, task.data, task.offset, (err, written) => {
if (err) {
if ((err as any).code === 'EAGAIN') {
this._timeout = setTimeout(() => this._processQueue(), 5);
} else {
this._writeQueue = [];
this.emit('error', err);
}
return;
}

task.offset += written;
if (task.offset >= task.data.byteLength) {
this._writeQueue.shift();
}

this._processQueue();
});
}
};
})();

export class UnixTerminal extends Terminal {
protected _fd: number;
protected _pty: string;
Expand All @@ -35,11 +150,6 @@ export class UnixTerminal extends Terminal {

private _boundClose: boolean = false;
private _emittedClose: boolean = false;

private readonly _writeQueue: (string | Buffer)[] = [];
private _writeInProgress: boolean = false;
private _writeTimeout: NodeJS.Timeout | undefined;

private _master: net.Socket | undefined;
private _slave: net.Socket | undefined;

Expand Down Expand Up @@ -107,7 +217,7 @@ export class UnixTerminal extends Terminal {
// fork
const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit);

this._socket = new tty.ReadStream(term.fd);
this._socket = new Socket(term.fd);
if (encoding !== null) {
this._socket.setEncoding(encoding);
}
Expand Down Expand Up @@ -168,69 +278,7 @@ export class UnixTerminal extends Terminal {
}

protected _write(data: string | Buffer): void {
// Writes are put in a queue and processed asynchronously in order to handle
// backpressure from the kernel buffer.
this._writeQueue.push(data);
if (this._writeInProgress) {
return;
}
this._writeInProgress = true;
this._processWriteQueue();
}

private async _processWriteQueue(): Promise<void> {
const data = this._writeQueue.shift();
if (!data) {
this._writeInProgress = false;
return;
}

// Write to the underlying file descriptor and handle it directly, rather
// than using the `net.Socket`/`tty.WriteStream` wrappers which swallow the
// errors and cause the thread to block indefinitely.
fs.write(this._fd, data, (err, written) => {
// Requeue any partial writes
if (written < data.length) {
this._writeQueue.unshift(data.slice(written));
}

if (err) {
const errno = (err as any).errno;
switch (errno) {
case -35: // EAGAIN (macOS)
case -11: // EAGAIN (Linux)
// This error appears to get swallowed and translated into
// `ERR_SYSTEM_ERROR` when using tty.WriteStream and not fs.write
// directly.
// This can happen during a regular partial write, but the most
// reliable way to test this is to run `sleep 10` in the shell and
// paste enough data to fill the kernel-level buffer. Once the sleep
// ends, the pty should accept the data again. Re-process after a
// short break.
this._writeTimeout = setTimeout(() => this._processWriteQueue(), 5);
return;
case -5: // EIO
case -32: // EPIPE
// Stop processing writes immediately as the pty is closed.
this._writeInProgress = false;
return;
default:
console.error('Unhandled pty write error', errno, err);
// Fall through as it's important to finish processing the queue
break;
}
}

// Using `setImmediate` here appears to corrupt the data, this may be what
// the interleaving/dropped data comment is about in Node.js' tty module:
// https://github.com/nodejs/node/blob/4cac2b94bed4bf02810be054e8f63c0048c66564/lib/tty.js#L106C1-L111C34
//
// Yielding via `setImmediate` also doesn't seem to drain the buffer much
// anyway, so use a short timeout when this happens instead. Note that the `drain`
// event does not appear to happen on `net.Socket`/`tty.WriteStream` when
// writing to ptys.
this._writeTimeout = setTimeout(() => this._processWriteQueue(), 5);
});
this._socket.write(data);
}

/* Accessors */
Expand Down Expand Up @@ -259,13 +307,13 @@ export class UnixTerminal extends Terminal {
// open
const term: IUnixOpenProcess = pty.open(cols, rows);

self._master = new tty.ReadStream(term.master);
self._master = new Socket(term.master);
if (encoding !== null) {
self._master.setEncoding(encoding);
}
self._master.resume();

self._slave = new tty.ReadStream(term.slave);
self._slave = new Socket(term.slave);
if (encoding !== null) {
self._slave.setEncoding(encoding);
}
Expand Down Expand Up @@ -306,8 +354,6 @@ export class UnixTerminal extends Terminal {
});

this._socket.destroy();

clearTimeout(this._writeTimeout);
}

public kill(signal?: string): void {
Expand All @@ -322,7 +368,7 @@ export class UnixTerminal extends Terminal {
public get process(): string {
if (process.platform === 'darwin') {
const title = pty.process(this._fd);
return (title !== 'kernel_task' ) ? title : this._file;
return (title !== 'kernel_task') ? title : this._file;
}

return pty.process(this._fd, this._pty) || this._file;
Expand Down