diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts index 5d2b36b3..98733dc0 100644 --- a/src/unixTerminal.ts +++ b/src/unixTerminal.ts @@ -9,7 +9,7 @@ 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'; +import { ArgvOrCommandLine, IDisposable } from './types'; import { assign, loadNativeModule } from './utils'; const native = loadNativeModule('pty'); @@ -23,13 +23,6 @@ const DEFAULT_FILE = 'sh'; const DEFAULT_NAME = 'xterm'; const DESTROY_SOCKET_TIMEOUT_MS = 200; -interface IWriteTask { - /** The buffer being written. */ - data: Buffer; - /** The current offset of not yet written data. */ - offset: number; -} - export class UnixTerminal extends Terminal { protected _fd: number; protected _pty: string; @@ -43,9 +36,7 @@ export class UnixTerminal extends Terminal { private _boundClose: boolean = false; private _emittedClose: boolean = false; - private readonly _writeQueue: IWriteTask[] = []; - private _writeImmediate: NodeJS.Immediate | undefined; - private _encoding?: BufferEncoding = undefined; + private _writeStream: CustomWriteStream; private _master: net.Socket | undefined; private _slave: net.Socket | undefined; @@ -83,7 +74,6 @@ export class UnixTerminal extends Terminal { const parsedEnv = this._parseEnv(env); const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); - this._encoding = (encoding || undefined) as BufferEncoding; const onexit = (code: number, signal: number): void => { // XXX Sometimes a data event is emitted after exit. Wait til socket is @@ -119,6 +109,7 @@ export class UnixTerminal extends Terminal { if (encoding !== null) { this._socket.setEncoding(encoding); } + this._writeStream = new CustomWriteStream(term.fd, (encoding || undefined) as BufferEncoding); // setup this._socket.on('error', (err: any) => { @@ -175,63 +166,8 @@ export class UnixTerminal extends Terminal { this._forwardEvents(); } - protected _write(str: string | Buffer): void { - // Writes are put in a queue and processed asynchronously in order to handle - // backpressure from the kernel buffer. - 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._processWriteQueue(); - } - } - } - - private _processWriteQueue(): void { - this._writeImmediate = undefined; - - if (this._writeQueue.length === 0) { - return; - } - - const task = this._writeQueue[0]; - - // Write to the underlying file descriptor and handle it directly, rather - // than using the `net.Socket`/`tty.WriteStream` wrappers which swallow and - // mask errors like EAGAIN and can cause the thread to block indefinitely. - fs.write(this._fd, task.data, task.offset, (err, written) => { - if (err) { - if ('code' in err && err.code === 'EAGAIN') { - // `setImmediate` is used to yield to the event loop and re-attempt - // the write later. - this._writeImmediate = setImmediate(() => this._processWriteQueue()); - } else { - // Stop processing immediately on unexpected error and log - this._writeQueue.length = 0; - console.error('Unhandled pty write error', err); - } - return; - } - - task.offset += written; - if (task.offset >= task.data.byteLength) { - this._writeQueue.shift(); - } - - // Since there is more room in the kernel buffer, we can continue to write - // until we hit EAGAIN or exhaust the queue. - // - // Note that old versions of bash, like v3.2 which ships in macOS, appears - // to have a bug in its readline implementation that causes data - // corruption when writes to the pty happens too quickly. Instead of - // trying to workaround that we just accept it so that large pastes are as - // fast as possible. - // Context: https://github.com/microsoft/node-pty/issues/833 - this._processWriteQueue(); - }); + protected _write(data: string | Buffer): void { + this._writeStream.write(data); } /* Accessors */ @@ -307,9 +243,7 @@ export class UnixTerminal extends Terminal { }); this._socket.destroy(); - - clearImmediate(this._writeImmediate); - this._writeImmediate = undefined; + this._writeStream.dispose(); } public kill(signal?: string): void { @@ -364,3 +298,91 @@ export class UnixTerminal extends Terminal { delete env['LINES']; } } + +interface IWriteTask { + /** The buffer being written. */ + buffer: Buffer; + /** The current offset of not yet written data. */ + offset: number; +} + +/** + * A custom write stream that writes directly to a file descriptor with proper + * handling of backpressure and errors. This avoids some event loop exhaustion + * issues that can occur when using the standard APIs in Node. + */ +class CustomWriteStream implements IDisposable { + + private readonly _writeQueue: IWriteTask[] = []; + private _writeImmediate: NodeJS.Immediate | undefined; + + constructor( + private readonly _fd: number, + private readonly _encoding: BufferEncoding + ) { + } + + dispose(): void { + clearImmediate(this._writeImmediate); + this._writeImmediate = undefined; + } + + write(data: string | Buffer): void { + // Writes are put in a queue and processed asynchronously in order to handle + // backpressure from the kernel buffer. + const buffer = typeof data === 'string' + ? Buffer.from(data, this._encoding) + : Buffer.from(data); + + if (buffer.byteLength !== 0) { + this._writeQueue.push({ buffer, offset: 0 }); + if (this._writeQueue.length === 1) { + this._processWriteQueue(); + } + } + } + + private _processWriteQueue(): void { + this._writeImmediate = undefined; + + if (this._writeQueue.length === 0) { + return; + } + + const task = this._writeQueue[0]; + + // Write to the underlying file descriptor and handle it directly, rather + // than using the `net.Socket`/`tty.WriteStream` wrappers which swallow and + // mask errors like EAGAIN and can cause the thread to block indefinitely. + fs.write(this._fd, task.buffer, task.offset, (err, written) => { + if (err) { + if ('code' in err && err.code === 'EAGAIN') { + // `setImmediate` is used to yield to the event loop and re-attempt + // the write later. + this._writeImmediate = setImmediate(() => this._processWriteQueue()); + } else { + // Stop processing immediately on unexpected error and log + this._writeQueue.length = 0; + console.error('Unhandled pty write error', err); + } + return; + } + + task.offset += written; + if (task.offset >= task.buffer.byteLength) { + this._writeQueue.shift(); + } + + // Since there is more room in the kernel buffer, we can continue to write + // until we hit EAGAIN or exhaust the queue. + // + // Note that old versions of bash, like v3.2 which ships in macOS, appears + // to have a bug in its readline implementation that causes data + // corruption when writes to the pty happens too quickly. Instead of + // trying to workaround that we just accept it so that large pastes are as + // fast as possible. + // Context: https://github.com/microsoft/node-pty/issues/833 + this._processWriteQueue(); + }); + } +}