1/* 2 * Copyright Node.js contributors. All rights reserved. 3 * 4 * Permission is hereby granted, free of charge, to any person obtaining a copy 5 * of this software and associated documentation files (the "Software"), to 6 * deal in the Software without restriction, including without limitation the 7 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 * sell copies of the Software, and to permit persons to whom the Software is 9 * furnished to do so, subject to the following conditions: 10 * 11 * The above copyright notice and this permission notice shall be included in 12 * all copies or substantial portions of the Software. 13 * 14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 * IN THE SOFTWARE. 21 */ 22'use strict'; 23const { spawn } = require('child_process'); 24const { EventEmitter } = require('events'); 25const net = require('net'); 26const util = require('util'); 27 28const runAsStandalone = typeof __dirname !== 'undefined'; 29 30const [ InspectClient, createRepl ] = 31 runAsStandalone ? 32 // This copy of node-inspect is on-disk, relative paths make sense. 33 [ 34 require('./internal/inspect_client'), 35 require('./internal/inspect_repl') 36 ] : 37 // This copy of node-inspect is built into the node executable. 38 [ 39 require('node-inspect/lib/internal/inspect_client'), 40 require('node-inspect/lib/internal/inspect_repl') 41 ]; 42 43const debuglog = util.debuglog('inspect'); 44 45class StartupError extends Error { 46 constructor(message) { 47 super(message); 48 this.name = 'StartupError'; 49 } 50} 51 52function portIsFree(host, port, timeout = 9999) { 53 if (port === 0) return Promise.resolve(); // Binding to a random port. 54 55 const retryDelay = 150; 56 let didTimeOut = false; 57 58 return new Promise((resolve, reject) => { 59 setTimeout(() => { 60 didTimeOut = true; 61 reject(new StartupError( 62 `Timeout (${timeout}) waiting for ${host}:${port} to be free`)); 63 }, timeout); 64 65 function pingPort() { 66 if (didTimeOut) return; 67 68 const socket = net.connect(port, host); 69 let didRetry = false; 70 function retry() { 71 if (!didRetry && !didTimeOut) { 72 didRetry = true; 73 setTimeout(pingPort, retryDelay); 74 } 75 } 76 77 socket.on('error', (error) => { 78 if (error.code === 'ECONNREFUSED') { 79 resolve(); 80 } else { 81 retry(); 82 } 83 }); 84 socket.on('connect', () => { 85 socket.destroy(); 86 retry(); 87 }); 88 } 89 pingPort(); 90 }); 91} 92 93function runScript(script, scriptArgs, inspectHost, inspectPort, childPrint) { 94 return portIsFree(inspectHost, inspectPort) 95 .then(() => { 96 return new Promise((resolve) => { 97 const needDebugBrk = process.version.match(/^v(6|7)\./); 98 const args = (needDebugBrk ? 99 ['--inspect', `--debug-brk=${inspectPort}`] : 100 [`--inspect-brk=${inspectPort}`]) 101 .concat([script], scriptArgs); 102 const child = spawn(process.execPath, args); 103 child.stdout.setEncoding('utf8'); 104 child.stderr.setEncoding('utf8'); 105 child.stdout.on('data', childPrint); 106 child.stderr.on('data', childPrint); 107 108 let output = ''; 109 function waitForListenHint(text) { 110 output += text; 111 if (/Debugger listening on ws:\/\/\[?(.+?)\]?:(\d+)\//.test(output)) { 112 const host = RegExp.$1; 113 const port = Number.parseInt(RegExp.$2); 114 child.stderr.removeListener('data', waitForListenHint); 115 resolve([child, port, host]); 116 } 117 } 118 119 child.stderr.on('data', waitForListenHint); 120 }); 121 }); 122} 123 124function createAgentProxy(domain, client) { 125 const agent = new EventEmitter(); 126 agent.then = (...args) => { 127 // TODO: potentially fetch the protocol and pretty-print it here. 128 const descriptor = { 129 [util.inspect.custom](depth, { stylize }) { 130 return stylize(`[Agent ${domain}]`, 'special'); 131 }, 132 }; 133 return Promise.resolve(descriptor).then(...args); 134 }; 135 136 return new Proxy(agent, { 137 get(target, name) { 138 if (name in target) return target[name]; 139 return function callVirtualMethod(params) { 140 return client.callMethod(`${domain}.${name}`, params); 141 }; 142 }, 143 }); 144} 145 146class NodeInspector { 147 constructor(options, stdin, stdout) { 148 this.options = options; 149 this.stdin = stdin; 150 this.stdout = stdout; 151 152 this.paused = true; 153 this.child = null; 154 155 if (options.script) { 156 this._runScript = runScript.bind(null, 157 options.script, 158 options.scriptArgs, 159 options.host, 160 options.port, 161 this.childPrint.bind(this)); 162 } else { 163 this._runScript = 164 () => Promise.resolve([null, options.port, options.host]); 165 } 166 167 this.client = new InspectClient(); 168 169 this.domainNames = ['Debugger', 'HeapProfiler', 'Profiler', 'Runtime']; 170 this.domainNames.forEach((domain) => { 171 this[domain] = createAgentProxy(domain, this.client); 172 }); 173 this.handleDebugEvent = (fullName, params) => { 174 const [domain, name] = fullName.split('.'); 175 if (domain in this) { 176 this[domain].emit(name, params); 177 } 178 }; 179 this.client.on('debugEvent', this.handleDebugEvent); 180 const startRepl = createRepl(this); 181 182 // Handle all possible exits 183 process.on('exit', () => this.killChild()); 184 process.once('SIGTERM', process.exit.bind(process, 0)); 185 process.once('SIGHUP', process.exit.bind(process, 0)); 186 187 this.run() 188 .then(() => startRepl()) 189 .then((repl) => { 190 this.repl = repl; 191 this.repl.on('exit', () => { 192 process.exit(0); 193 }); 194 this.paused = false; 195 }) 196 .then(null, (error) => process.nextTick(() => { throw error; })); 197 } 198 199 suspendReplWhile(fn) { 200 if (this.repl) { 201 this.repl.pause(); 202 } 203 this.stdin.pause(); 204 this.paused = true; 205 return new Promise((resolve) => { 206 resolve(fn()); 207 }).then(() => { 208 this.paused = false; 209 if (this.repl) { 210 this.repl.resume(); 211 this.repl.displayPrompt(); 212 } 213 this.stdin.resume(); 214 }).then(null, (error) => process.nextTick(() => { throw error; })); 215 } 216 217 killChild() { 218 this.client.reset(); 219 if (this.child) { 220 this.child.kill(); 221 this.child = null; 222 } 223 } 224 225 run() { 226 this.killChild(); 227 228 return this._runScript().then(([child, port, host]) => { 229 this.child = child; 230 231 let connectionAttempts = 0; 232 const attemptConnect = () => { 233 ++connectionAttempts; 234 debuglog('connection attempt #%d', connectionAttempts); 235 this.stdout.write('.'); 236 return this.client.connect(port, host) 237 .then(() => { 238 debuglog('connection established'); 239 this.stdout.write(' ok'); 240 }, (error) => { 241 debuglog('connect failed', error); 242 // If it's failed to connect 10 times then print failed message 243 if (connectionAttempts >= 10) { 244 this.stdout.write(' failed to connect, please retry\n'); 245 process.exit(1); 246 } 247 248 return new Promise((resolve) => setTimeout(resolve, 500)) 249 .then(attemptConnect); 250 }); 251 }; 252 253 this.print(`connecting to ${host}:${port} ..`, true); 254 return attemptConnect(); 255 }); 256 } 257 258 clearLine() { 259 if (this.stdout.isTTY) { 260 this.stdout.cursorTo(0); 261 this.stdout.clearLine(1); 262 } else { 263 this.stdout.write('\b'); 264 } 265 } 266 267 print(text, oneline = false) { 268 this.clearLine(); 269 this.stdout.write(oneline ? text : `${text}\n`); 270 } 271 272 childPrint(text) { 273 this.print( 274 text.toString() 275 .split(/\r\n|\r|\n/g) 276 .filter((chunk) => !!chunk) 277 .map((chunk) => `< ${chunk}`) 278 .join('\n') 279 ); 280 if (!this.paused) { 281 this.repl.displayPrompt(true); 282 } 283 if (/Waiting for the debugger to disconnect\.\.\.\n$/.test(text)) { 284 this.killChild(); 285 } 286 } 287} 288 289function parseArgv([target, ...args]) { 290 let host = '127.0.0.1'; 291 let port = 9229; 292 let isRemote = false; 293 let script = target; 294 let scriptArgs = args; 295 296 const hostMatch = target.match(/^([^:]+):(\d+)$/); 297 const portMatch = target.match(/^--port=(\d+)$/); 298 299 if (hostMatch) { 300 // Connecting to remote debugger 301 // `node-inspect localhost:9229` 302 host = hostMatch[1]; 303 port = parseInt(hostMatch[2], 10); 304 isRemote = true; 305 script = null; 306 } else if (portMatch) { 307 // start debugee on custom port 308 // `node inspect --port=9230 script.js` 309 port = parseInt(portMatch[1], 10); 310 script = args[0]; 311 scriptArgs = args.slice(1); 312 } else if (args.length === 1 && /^\d+$/.test(args[0]) && target === '-p') { 313 // Start debugger against a given pid 314 const pid = parseInt(args[0], 10); 315 try { 316 process._debugProcess(pid); 317 } catch (e) { 318 if (e.code === 'ESRCH') { 319 /* eslint-disable no-console */ 320 console.error(`Target process: ${pid} doesn't exist.`); 321 /* eslint-enable no-console */ 322 process.exit(1); 323 } 324 throw e; 325 } 326 script = null; 327 isRemote = true; 328 } 329 330 return { 331 host, port, isRemote, script, scriptArgs, 332 }; 333} 334 335function startInspect(argv = process.argv.slice(2), 336 stdin = process.stdin, 337 stdout = process.stdout) { 338 /* eslint-disable no-console */ 339 if (argv.length < 1) { 340 const invokedAs = runAsStandalone ? 341 'node-inspect' : 342 `${process.argv0} ${process.argv[1]}`; 343 344 console.error(`Usage: ${invokedAs} script.js`); 345 console.error(` ${invokedAs} <host>:<port>`); 346 console.error(` ${invokedAs} -p <pid>`); 347 process.exit(1); 348 } 349 350 const options = parseArgv(argv); 351 const inspector = new NodeInspector(options, stdin, stdout); 352 353 stdin.resume(); 354 355 function handleUnexpectedError(e) { 356 if (!(e instanceof StartupError)) { 357 console.error('There was an internal error in node-inspect. ' + 358 'Please report this bug.'); 359 console.error(e.message); 360 console.error(e.stack); 361 } else { 362 console.error(e.message); 363 } 364 if (inspector.child) inspector.child.kill(); 365 process.exit(1); 366 } 367 368 process.on('uncaughtException', handleUnexpectedError); 369 /* eslint-enable no-console */ 370} 371exports.start = startInspect; 372