Files
node/lib/internal/debugger/inspect_client.js
T
Joyee Cheung 91dc00a2c1 debugger: fix event listener leak in the run command
It should remove both the error and the ready event listeners
attached when either of them fires, instead of removing only
the one whose corresponding event fires, otherwise the other
event listener will always get leaked.

PR-URL: https://github.com/nodejs/node/pull/60464
Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
2026-02-24 00:36:44 +01:00

354 lines
9.6 KiB
JavaScript

'use strict';
const {
ArrayPrototypePush,
ErrorCaptureStackTrace,
FunctionPrototypeBind,
JSONParse,
JSONStringify,
ObjectKeys,
Promise,
} = primordials;
const Buffer = require('buffer').Buffer;
const crypto = require('crypto');
const { ERR_DEBUGGER_ERROR } = require('internal/errors').codes;
const { EventEmitter, once } = require('events');
const http = require('http');
const { URL } = require('internal/url');
const debuglog = require('internal/util/debuglog').debuglog('inspect');
const kOpCodeText = 0x1;
const kOpCodeClose = 0x8;
const kFinalBit = 0x80;
const kReserved1Bit = 0x40;
const kReserved2Bit = 0x20;
const kReserved3Bit = 0x10;
const kOpCodeMask = 0xF;
const kMaskBit = 0x80;
const kPayloadLengthMask = 0x7F;
const kMaxSingleBytePayloadLength = 125;
const kMaxTwoBytePayloadLength = 0xFFFF;
const kTwoBytePayloadLengthField = 126;
const kEightBytePayloadLengthField = 127;
const kMaskingKeyWidthInBytes = 4;
// This guid is defined in the Websocket Protocol RFC
// https://tools.ietf.org/html/rfc6455#section-1.3
const WEBSOCKET_HANDSHAKE_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function unpackError({ code, message }) {
const err = new ERR_DEBUGGER_ERROR(`${message}`);
err.code = code;
ErrorCaptureStackTrace(err, unpackError);
return err;
}
function validateHandshake(requestKey, responseKey) {
const expectedResponseKeyBase = requestKey + WEBSOCKET_HANDSHAKE_GUID;
const shasum = crypto.createHash('sha1');
shasum.update(expectedResponseKeyBase);
const shabuf = shasum.digest();
if (shabuf.toString('base64') !== responseKey) {
throw new ERR_DEBUGGER_ERROR(
`WebSocket secret mismatch: ${requestKey} did not match ${responseKey}`,
);
}
}
function encodeFrameHybi17(payload) {
const dataLength = payload.length;
let singleByteLength;
let additionalLength;
if (dataLength > kMaxTwoBytePayloadLength) {
singleByteLength = kEightBytePayloadLengthField;
additionalLength = Buffer.alloc(8);
let remaining = dataLength;
for (let i = 0; i < 8; ++i) {
additionalLength[7 - i] = remaining & 0xFF;
remaining >>= 8;
}
} else if (dataLength > kMaxSingleBytePayloadLength) {
singleByteLength = kTwoBytePayloadLengthField;
additionalLength = Buffer.alloc(2);
additionalLength[0] = (dataLength & 0xFF00) >> 8;
additionalLength[1] = dataLength & 0xFF;
} else {
additionalLength = Buffer.alloc(0);
singleByteLength = dataLength;
}
const header = Buffer.from([
kFinalBit | kOpCodeText,
kMaskBit | singleByteLength,
]);
const mask = Buffer.alloc(4);
const masked = Buffer.alloc(dataLength);
for (let i = 0; i < dataLength; ++i) {
masked[i] = payload[i] ^ mask[i % kMaskingKeyWidthInBytes];
}
return Buffer.concat([header, additionalLength, mask, masked]);
}
function decodeFrameHybi17(data) {
const dataAvailable = data.length;
const notComplete = { closed: false, payload: null, rest: data };
let payloadOffset = 2;
if ((dataAvailable - payloadOffset) < 0) return notComplete;
const firstByte = data[0];
const secondByte = data[1];
const final = (firstByte & kFinalBit) !== 0;
const reserved1 = (firstByte & kReserved1Bit) !== 0;
const reserved2 = (firstByte & kReserved2Bit) !== 0;
const reserved3 = (firstByte & kReserved3Bit) !== 0;
const opCode = firstByte & kOpCodeMask;
const masked = (secondByte & kMaskBit) !== 0;
const compressed = reserved1;
if (compressed) {
throw new ERR_DEBUGGER_ERROR('Compressed frames not supported');
}
if (!final || reserved2 || reserved3) {
throw new ERR_DEBUGGER_ERROR('Only compression extension is supported');
}
if (masked) {
throw new ERR_DEBUGGER_ERROR('Masked server frame - not supported');
}
let closed = false;
switch (opCode) {
case kOpCodeClose:
closed = true;
break;
case kOpCodeText:
break;
default:
throw new ERR_DEBUGGER_ERROR(`Unsupported op code ${opCode}`);
}
let payloadLength = secondByte & kPayloadLengthMask;
switch (payloadLength) {
case kTwoBytePayloadLengthField:
payloadOffset += 2;
payloadLength = (data[2] << 8) + data[3];
break;
case kEightBytePayloadLengthField:
payloadOffset += 8;
payloadLength = 0;
for (let i = 0; i < 8; ++i) {
payloadLength <<= 8;
payloadLength |= data[2 + i];
}
break;
default:
// Nothing. We already have the right size.
}
if ((dataAvailable - payloadOffset - payloadLength) < 0) return notComplete;
const payloadEnd = payloadOffset + payloadLength;
return {
payload: data.slice(payloadOffset, payloadEnd),
rest: data.slice(payloadEnd),
closed,
};
}
class Client extends EventEmitter {
constructor() {
super();
this.handleChunk = FunctionPrototypeBind(this._handleChunk, this);
this._port = undefined;
this._host = undefined;
this.reset();
}
_handleChunk(chunk) {
this._unprocessed = Buffer.concat([this._unprocessed, chunk]);
while (this._unprocessed.length > 2) {
const {
closed,
payload: payloadBuffer,
rest,
} = decodeFrameHybi17(this._unprocessed);
this._unprocessed = rest;
if (closed) {
this.reset();
return;
}
if (payloadBuffer === null || payloadBuffer.length === 0) break;
const payloadStr = payloadBuffer.toString();
debuglog('< %s', payloadStr);
const lastChar = payloadStr[payloadStr.length - 1];
if (payloadStr[0] !== '{' || lastChar !== '}') {
throw new ERR_DEBUGGER_ERROR(`Payload does not look like JSON: ${payloadStr}`);
}
let payload;
try {
payload = JSONParse(payloadStr);
} catch (parseError) {
parseError.string = payloadStr;
throw parseError;
}
const { id, method, params, result, error } = payload;
if (id) {
const handler = this._pending[id];
if (handler) {
delete this._pending[id];
handler(error, result);
}
} else if (method) {
this.emit('debugEvent', method, params);
this.emit(method, params);
} else {
throw new ERR_DEBUGGER_ERROR(`Unsupported response: ${payloadStr}`);
}
}
}
reset() {
if (this._http) {
this._http.destroy();
}
if (this._socket) {
this._socket.destroy();
}
this._http = null;
this._lastId = 0;
this._socket = null;
this._pending = {};
this._unprocessed = Buffer.alloc(0);
}
callMethod(method, params) {
return new Promise((resolve, reject) => {
if (!this._socket) {
reject(new ERR_DEBUGGER_ERROR('Use `run` to start the app again.'));
return;
}
const data = { id: ++this._lastId, method, params };
this._pending[data.id] = (error, result) => {
if (error) reject(unpackError(error));
else resolve(ObjectKeys(result).length ? result : undefined);
};
const json = JSONStringify(data);
debuglog('> %s', json);
this._socket.write(encodeFrameHybi17(Buffer.from(json)));
});
}
_fetchJSON(urlPath) {
return new Promise((resolve, reject) => {
const httpReq = http.get({
host: this._host,
port: this._port,
path: urlPath,
});
const chunks = [];
function onResponse(httpRes) {
function parseChunks() {
const resBody = Buffer.concat(chunks).toString();
if (httpRes.statusCode !== 200) {
reject(new ERR_DEBUGGER_ERROR(`Unexpected ${httpRes.statusCode}: ${resBody}`));
return;
}
try {
resolve(JSONParse(resBody));
} catch {
reject(new ERR_DEBUGGER_ERROR(`Response didn't contain JSON: ${resBody}`));
}
}
httpRes.on('error', reject);
httpRes.on('data', (chunk) => ArrayPrototypePush(chunks, chunk));
httpRes.on('end', parseChunks);
}
httpReq.on('error', reject);
httpReq.on('response', onResponse);
});
}
async connect(port, host) {
this._port = port;
this._host = host;
const urlPath = await this._discoverWebsocketPath();
return this._connectWebsocket(urlPath);
}
async _discoverWebsocketPath() {
const { 0: { webSocketDebuggerUrl } } = await this._fetchJSON('/json');
const { pathname, search } = new URL(webSocketDebuggerUrl);
return `${pathname}${search}`;
}
_connectWebsocket(urlPath) {
this.reset();
const requestKey = crypto.randomBytes(16).toString('base64');
debuglog('request WebSocket', requestKey);
const httpReq = this._http = http.request({
host: this._host,
port: this._port,
path: urlPath,
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Key': requestKey,
'Sec-WebSocket-Version': '13',
},
});
httpReq.on('error', (e) => {
this.emit('error', e);
});
httpReq.on('response', (httpRes) => {
if (httpRes.statusCode >= 400) {
process.stderr.write(`Unexpected HTTP code: ${httpRes.statusCode}\n`);
httpRes.pipe(process.stderr);
} else {
httpRes.pipe(process.stderr);
}
});
const handshakeListener = (res, socket) => {
validateHandshake(requestKey, res.headers['sec-websocket-accept']);
debuglog('websocket upgrade');
this._socket = socket;
socket.on('data', this.handleChunk);
socket.on('close', () => {
this.emit('close');
});
this.emit('ready');
};
const onReady = once(this, 'ready');
httpReq.on('upgrade', handshakeListener);
httpReq.end();
return onReady;
}
}
module.exports = Client;