• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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