1'use strict'; 2const common = require('../common'); 3const assert = require('assert'); 4const fs = require('fs'); 5const http = require('http'); 6const fixtures = require('../common/fixtures'); 7const { spawn } = require('child_process'); 8const { parse: parseURL } = require('url'); 9const { pathToFileURL } = require('url'); 10const { EventEmitter } = require('events'); 11 12const _MAINSCRIPT = fixtures.path('loop.js'); 13const DEBUG = false; 14const TIMEOUT = common.platformTimeout(15 * 1000); 15 16function spawnChildProcess(inspectorFlags, scriptContents, scriptFile) { 17 const args = [].concat(inspectorFlags); 18 if (scriptContents) { 19 args.push('-e', scriptContents); 20 } else { 21 args.push(scriptFile); 22 } 23 const child = spawn(process.execPath, args); 24 25 const handler = tearDown.bind(null, child); 26 process.on('exit', handler); 27 process.on('uncaughtException', handler); 28 common.disableCrashOnUnhandledRejection(); 29 process.on('unhandledRejection', handler); 30 process.on('SIGINT', handler); 31 32 return child; 33} 34 35function makeBufferingDataCallback(dataCallback) { 36 let buffer = Buffer.alloc(0); 37 return (data) => { 38 const newData = Buffer.concat([buffer, data]); 39 const str = newData.toString('utf8'); 40 const lines = str.replace(/\r/g, '').split('\n'); 41 if (str.endsWith('\n')) 42 buffer = Buffer.alloc(0); 43 else 44 buffer = Buffer.from(lines.pop(), 'utf8'); 45 for (const line of lines) 46 dataCallback(line); 47 }; 48} 49 50function tearDown(child, err) { 51 child.kill(); 52 if (err) { 53 console.error(err); 54 process.exit(1); 55 } 56} 57 58function parseWSFrame(buffer) { 59 // Protocol described in https://tools.ietf.org/html/rfc6455#section-5 60 let message = null; 61 if (buffer.length < 2) 62 return { length: 0, message }; 63 if (buffer[0] === 0x88 && buffer[1] === 0x00) { 64 return { length: 2, message, closed: true }; 65 } 66 assert.strictEqual(buffer[0], 0x81); 67 let dataLen = 0x7F & buffer[1]; 68 let bodyOffset = 2; 69 if (buffer.length < bodyOffset + dataLen) 70 return 0; 71 if (dataLen === 126) { 72 dataLen = buffer.readUInt16BE(2); 73 bodyOffset = 4; 74 } else if (dataLen === 127) { 75 assert(buffer[2] === 0 && buffer[3] === 0, 'Inspector message too big'); 76 dataLen = buffer.readUIntBE(4, 6); 77 bodyOffset = 10; 78 } 79 if (buffer.length < bodyOffset + dataLen) 80 return { length: 0, message }; 81 const jsonPayload = 82 buffer.slice(bodyOffset, bodyOffset + dataLen).toString('utf8'); 83 try { 84 message = JSON.parse(jsonPayload); 85 } catch (e) { 86 console.error(`JSON.parse() failed for: ${jsonPayload}`); 87 throw e; 88 } 89 if (DEBUG) 90 console.log('[received]', JSON.stringify(message)); 91 return { length: bodyOffset + dataLen, message }; 92} 93 94function formatWSFrame(message) { 95 const messageBuf = Buffer.from(JSON.stringify(message)); 96 97 const wsHeaderBuf = Buffer.allocUnsafe(16); 98 wsHeaderBuf.writeUInt8(0x81, 0); 99 let byte2 = 0x80; 100 const bodyLen = messageBuf.length; 101 102 let maskOffset = 2; 103 if (bodyLen < 126) { 104 byte2 = 0x80 + bodyLen; 105 } else if (bodyLen < 65536) { 106 byte2 = 0xFE; 107 wsHeaderBuf.writeUInt16BE(bodyLen, 2); 108 maskOffset = 4; 109 } else { 110 byte2 = 0xFF; 111 wsHeaderBuf.writeUInt32BE(bodyLen, 2); 112 wsHeaderBuf.writeUInt32BE(0, 6); 113 maskOffset = 10; 114 } 115 wsHeaderBuf.writeUInt8(byte2, 1); 116 wsHeaderBuf.writeUInt32BE(0x01020408, maskOffset); 117 118 for (let i = 0; i < messageBuf.length; i++) 119 messageBuf[i] = messageBuf[i] ^ (1 << (i % 4)); 120 121 return Buffer.concat([wsHeaderBuf.slice(0, maskOffset + 4), messageBuf]); 122} 123 124class InspectorSession { 125 constructor(socket, instance) { 126 this._instance = instance; 127 this._socket = socket; 128 this._nextId = 1; 129 this._commandResponsePromises = new Map(); 130 this._unprocessedNotifications = []; 131 this._notificationCallback = null; 132 this._scriptsIdsByUrl = new Map(); 133 this._pausedDetails = null; 134 135 let buffer = Buffer.alloc(0); 136 socket.on('data', (data) => { 137 buffer = Buffer.concat([buffer, data]); 138 do { 139 const { length, message, closed } = parseWSFrame(buffer); 140 if (!length) 141 break; 142 143 if (closed) { 144 socket.write(Buffer.from([0x88, 0x00])); // WS close frame 145 } 146 buffer = buffer.slice(length); 147 if (message) 148 this._onMessage(message); 149 } while (true); 150 }); 151 this._terminationPromise = new Promise((resolve) => { 152 socket.once('close', resolve); 153 }); 154 } 155 156 waitForServerDisconnect() { 157 return this._terminationPromise; 158 } 159 160 async disconnect() { 161 this._socket.destroy(); 162 return this.waitForServerDisconnect(); 163 } 164 165 _onMessage(message) { 166 if (message.id) { 167 const { resolve, reject } = this._commandResponsePromises.get(message.id); 168 this._commandResponsePromises.delete(message.id); 169 if (message.result) 170 resolve(message.result); 171 else 172 reject(message.error); 173 } else { 174 if (message.method === 'Debugger.scriptParsed') { 175 const { scriptId, url } = message.params; 176 this._scriptsIdsByUrl.set(scriptId, url); 177 const fileUrl = url.startsWith('file:') ? 178 url : pathToFileURL(url).toString(); 179 if (fileUrl === this.scriptURL().toString()) { 180 this.mainScriptId = scriptId; 181 } 182 } 183 if (message.method === 'Debugger.paused') 184 this._pausedDetails = message.params; 185 if (message.method === 'Debugger.resumed') 186 this._pausedDetails = null; 187 188 if (this._notificationCallback) { 189 // In case callback needs to install another 190 const callback = this._notificationCallback; 191 this._notificationCallback = null; 192 callback(message); 193 } else { 194 this._unprocessedNotifications.push(message); 195 } 196 } 197 } 198 199 unprocessedNotifications() { 200 return this._unprocessedNotifications; 201 } 202 203 _sendMessage(message) { 204 const msg = JSON.parse(JSON.stringify(message)); // Clone! 205 msg.id = this._nextId++; 206 if (DEBUG) 207 console.log('[sent]', JSON.stringify(msg)); 208 209 const responsePromise = new Promise((resolve, reject) => { 210 this._commandResponsePromises.set(msg.id, { resolve, reject }); 211 }); 212 213 return new Promise( 214 (resolve) => this._socket.write(formatWSFrame(msg), resolve)) 215 .then(() => responsePromise); 216 } 217 218 send(commands) { 219 if (Array.isArray(commands)) { 220 // Multiple commands means the response does not matter. There might even 221 // never be a response. 222 return Promise 223 .all(commands.map((command) => this._sendMessage(command))) 224 .then(() => {}); 225 } 226 return this._sendMessage(commands); 227 } 228 229 waitForNotification(methodOrPredicate, description) { 230 const desc = description || methodOrPredicate; 231 const message = `Timed out waiting for matching notification (${desc})`; 232 return fires( 233 this._asyncWaitForNotification(methodOrPredicate), message, TIMEOUT); 234 } 235 236 async _asyncWaitForNotification(methodOrPredicate) { 237 function matchMethod(notification) { 238 return notification.method === methodOrPredicate; 239 } 240 const predicate = 241 typeof methodOrPredicate === 'string' ? matchMethod : methodOrPredicate; 242 let notification = null; 243 do { 244 if (this._unprocessedNotifications.length) { 245 notification = this._unprocessedNotifications.shift(); 246 } else { 247 notification = await new Promise( 248 (resolve) => this._notificationCallback = resolve); 249 } 250 } while (!predicate(notification)); 251 return notification; 252 } 253 254 _isBreakOnLineNotification(message, line, expectedScriptPath) { 255 if (message.method === 'Debugger.paused') { 256 const callFrame = message.params.callFrames[0]; 257 const location = callFrame.location; 258 const scriptPath = this._scriptsIdsByUrl.get(location.scriptId); 259 assert.strictEqual(scriptPath.toString(), 260 expectedScriptPath.toString(), 261 `${scriptPath} !== ${expectedScriptPath}`); 262 assert.strictEqual(location.lineNumber, line); 263 return true; 264 } 265 } 266 267 waitForBreakOnLine(line, url) { 268 return this 269 .waitForNotification( 270 (notification) => 271 this._isBreakOnLineNotification(notification, line, url), 272 `break on ${url}:${line}`); 273 } 274 275 pausedDetails() { 276 return this._pausedDetails; 277 } 278 279 _matchesConsoleOutputNotification(notification, type, values) { 280 if (!Array.isArray(values)) 281 values = [ values ]; 282 if (notification.method === 'Runtime.consoleAPICalled') { 283 const params = notification.params; 284 if (params.type === type) { 285 let i = 0; 286 for (const value of params.args) { 287 if (value.value !== values[i++]) 288 return false; 289 } 290 return i === values.length; 291 } 292 } 293 } 294 295 waitForConsoleOutput(type, values) { 296 const desc = `Console output matching ${JSON.stringify(values)}`; 297 return this.waitForNotification( 298 (notification) => this._matchesConsoleOutputNotification(notification, 299 type, values), 300 desc); 301 } 302 303 async runToCompletion() { 304 console.log('[test]', 'Verify node waits for the frontend to disconnect'); 305 await this.send({ 'method': 'Debugger.resume' }); 306 await this.waitForNotification((notification) => { 307 return notification.method === 'Runtime.executionContextDestroyed' && 308 notification.params.executionContextId === 1; 309 }); 310 while ((await this._instance.nextStderrString()) !== 311 'Waiting for the debugger to disconnect...'); 312 await this.disconnect(); 313 } 314 315 scriptPath() { 316 return this._instance.scriptPath(); 317 } 318 319 script() { 320 return this._instance.script(); 321 } 322 323 scriptURL() { 324 return pathToFileURL(this.scriptPath()); 325 } 326} 327 328class NodeInstance extends EventEmitter { 329 constructor(inspectorFlags = ['--inspect-brk=0', '--expose-internals'], 330 scriptContents = '', 331 scriptFile = _MAINSCRIPT) { 332 super(); 333 334 this._scriptPath = scriptFile; 335 this._script = scriptFile ? null : scriptContents; 336 this._portCallback = null; 337 this.portPromise = new Promise((resolve) => this._portCallback = resolve); 338 this._process = spawnChildProcess(inspectorFlags, scriptContents, 339 scriptFile); 340 this._running = true; 341 this._stderrLineCallback = null; 342 this._unprocessedStderrLines = []; 343 344 this._process.stdout.on('data', makeBufferingDataCallback( 345 (line) => { 346 this.emit('stdout', line); 347 console.log('[out]', line); 348 })); 349 350 this._process.stderr.on('data', makeBufferingDataCallback( 351 (message) => this.onStderrLine(message))); 352 353 this._shutdownPromise = new Promise((resolve) => { 354 this._process.once('exit', (exitCode, signal) => { 355 if (signal) { 356 console.error(`[err] child process crashed, signal ${signal}`); 357 } 358 resolve({ exitCode, signal }); 359 this._running = false; 360 }); 361 }); 362 } 363 364 static async startViaSignal(scriptContents) { 365 const instance = new NodeInstance( 366 ['--expose-internals'], 367 `${scriptContents}\nprocess._rawDebug('started');`, undefined); 368 const msg = 'Timed out waiting for process to start'; 369 while (await fires(instance.nextStderrString(), msg, TIMEOUT) !== 370 'started') {} 371 process._debugProcess(instance._process.pid); 372 return instance; 373 } 374 375 onStderrLine(line) { 376 console.log('[err]', line); 377 if (this._portCallback) { 378 const matches = line.match(/Debugger listening on ws:\/\/.+:(\d+)\/.+/); 379 if (matches) { 380 this._portCallback(matches[1]); 381 this._portCallback = null; 382 } 383 } 384 if (this._stderrLineCallback) { 385 this._stderrLineCallback(line); 386 this._stderrLineCallback = null; 387 } else { 388 this._unprocessedStderrLines.push(line); 389 } 390 } 391 392 httpGet(host, path, hostHeaderValue) { 393 console.log('[test]', `Testing ${path}`); 394 const headers = hostHeaderValue ? { 'Host': hostHeaderValue } : null; 395 return this.portPromise.then((port) => new Promise((resolve, reject) => { 396 const req = http.get({ host, port, path, headers }, (res) => { 397 let response = ''; 398 res.setEncoding('utf8'); 399 res 400 .on('data', (data) => response += data.toString()) 401 .on('end', () => { 402 resolve(response); 403 }); 404 }); 405 req.on('error', reject); 406 })).then((response) => { 407 try { 408 return JSON.parse(response); 409 } catch (e) { 410 e.body = response; 411 throw e; 412 } 413 }); 414 } 415 416 async sendUpgradeRequest() { 417 const response = await this.httpGet(null, '/json/list'); 418 const devtoolsUrl = response[0].webSocketDebuggerUrl; 419 const port = await this.portPromise; 420 return http.get({ 421 port, 422 path: parseURL(devtoolsUrl).path, 423 headers: { 424 'Connection': 'Upgrade', 425 'Upgrade': 'websocket', 426 'Sec-WebSocket-Version': 13, 427 'Sec-WebSocket-Key': 'key==' 428 } 429 }); 430 } 431 432 async connectInspectorSession() { 433 console.log('[test]', 'Connecting to a child Node process'); 434 const upgradeRequest = await this.sendUpgradeRequest(); 435 return new Promise((resolve) => { 436 upgradeRequest 437 .on('upgrade', 438 (message, socket) => resolve(new InspectorSession(socket, this))) 439 .on('response', common.mustNotCall('Upgrade was not received')); 440 }); 441 } 442 443 async expectConnectionDeclined() { 444 console.log('[test]', 'Checking upgrade is not possible'); 445 const upgradeRequest = await this.sendUpgradeRequest(); 446 return new Promise((resolve) => { 447 upgradeRequest 448 .on('upgrade', common.mustNotCall('Upgrade was received')) 449 .on('response', (response) => 450 response.on('data', () => {}) 451 .on('end', () => resolve(response.statusCode))); 452 }); 453 } 454 455 expectShutdown() { 456 return this._shutdownPromise; 457 } 458 459 nextStderrString() { 460 if (this._unprocessedStderrLines.length) 461 return Promise.resolve(this._unprocessedStderrLines.shift()); 462 return new Promise((resolve) => this._stderrLineCallback = resolve); 463 } 464 465 write(message) { 466 this._process.stdin.write(message); 467 } 468 469 kill() { 470 this._process.kill(); 471 return this.expectShutdown(); 472 } 473 474 scriptPath() { 475 return this._scriptPath; 476 } 477 478 script() { 479 if (this._script === null) 480 this._script = fs.readFileSync(this.scriptPath(), 'utf8'); 481 return this._script; 482 } 483} 484 485function onResolvedOrRejected(promise, callback) { 486 return promise.then((result) => { 487 callback(); 488 return result; 489 }, (error) => { 490 callback(); 491 throw error; 492 }); 493} 494 495function timeoutPromise(error, timeoutMs) { 496 let clearCallback = null; 497 let done = false; 498 const promise = onResolvedOrRejected(new Promise((resolve, reject) => { 499 const timeout = setTimeout(() => reject(error), timeoutMs); 500 clearCallback = () => { 501 if (done) 502 return; 503 clearTimeout(timeout); 504 resolve(); 505 }; 506 }), () => done = true); 507 promise.clear = clearCallback; 508 return promise; 509} 510 511// Returns a new promise that will propagate `promise` resolution or rejection 512// if that happens within the `timeoutMs` timespan, or rejects with `error` as 513// a reason otherwise. 514function fires(promise, error, timeoutMs) { 515 const timeout = timeoutPromise(error, timeoutMs); 516 return Promise.race([ 517 onResolvedOrRejected(promise, () => timeout.clear()), 518 timeout, 519 ]); 520} 521 522module.exports = { 523 NodeInstance 524}; 525