current commit
260718.6 · repl: use inspector over vm
'use strict';

const {
  BigInt,
  JSONStringify,
  ObjectDefineProperty,
  Promise,
  PromisePrototypeThen,
  ReflectHas,
  StringPrototypeSlice,
  Symbol,
} = primordials;

const {
  codes: {
    ERR_INSPECTOR_NOT_AVAILABLE,
    ERR_SCRIPT_EXECUTION_INTERRUPTED,
  },
} = require('internal/errors');
const { runInterruptible } = internalBinding('contextify');
const { hasInspector } = internalBinding('config');

let Session;

// Give each REPL its own object group. Multiple REPLs can evaluate
// concurrently, and releasing a shared group from one channel could otherwise
// collect a promise that another channel is still awaiting.
let nextObjectGroupId = 0;

// Property used to ferry a live value from the inspector world back to ours.
const kStashKey = '__nodeREPLStash';
const kStashFn = `function() {
  globalThis[${JSONStringify(kStashKey)}].value = this;
}`;

// Cached channel, attached to the REPL server instance.
const kChannel = Symbol('kReplInspectorChannel');

function prepareContext(context) {
  if (!ReflectHas(context, kStashKey)) {
    ObjectDefineProperty(context, kStashKey, {
      __proto__: null,
      value: { __proto__: null },
      enumerable: false,
      writable: false,
      configurable: true,
    });
  }
}

// Converts the `unserializableValue` field of a remote object back into the
// real primitive it represents.
function fromUnserializable(value) {
  switch (value) {
    case 'NaN':
      return NaN;
    case 'Infinity':
      return Infinity;
    case '-Infinity':
      return -Infinity;
    case '-0':
      return -0;
    default:
      // BigInt literals arrive as e.g. "123n".
      if (typeof value === 'string' && value[value.length - 1] === 'n') {
        try {
          return BigInt(StringPrototypeSlice(value, 0, -1));
        } catch {
          // Fall through and return the raw string.
        }
      }
      return value;
  }
}

class ReplInspectorChannel {
  constructor(repl) {
    this.repl = repl;
    this.session = null;
    // Exposed so callers can scope their own remote handles to the shared group.
    this.objectGroup = `node-repl-${nextObjectGroupId++}`;
  }

  // Lazily connects the shared session and arranges for it to be torn down on
  // REPL exit.
  getSession() {
    if (this.session !== null) {
      return this.session;
    }
    Session ??= require('inspector').Session;
    const session = this.session = new Session();
    session.connect();
    session.post('Runtime.enable');
    this.repl.once('exit', () => {
      this.session = null;
      try {
        session.post('Runtime.disable');
        session.disconnect();
      } catch {
        // The session may already be gone; nothing else to do.
      }
    });
    return session;
  }

  // Issues a single inspector command.
  post(method, params) {
    return new Promise((resolve, reject) => {
      this.getSession().post(method, params, (error, result) => {
        if (error) {
          reject(error);
        } else {
          resolve(result);
        }
      });
    });
  }

  // Issues an inspector command that may be interrupted by SIGINT.
  postInterruptible(method, params, breakOnSigint) {
    if (!breakOnSigint) {
      return this.post(method, params);
    }
    return new Promise((resolve, reject) => {
      let fired = false;
      let cbError;
      let cbResult;
      let onLate = null;
      const handle = (error, result) => {
        fired = true;
        cbError = error;
        cbResult = result;
        if (onLate !== null) {
          onLate();
        }
      };
      // Let the terminal driver turn Ctrl+C into SIGINT while synchronous
      // inspector dispatch is blocking the main thread.
      const previouslyInRawMode = this.repl._setRawMode(false);
      let interrupted;
      try {
        interrupted = runInterruptible(() => {
          this.getSession().post(method, params, handle);
        });
      } finally {
        this.repl._setRawMode(previouslyInRawMode);
      }
      if (interrupted) {
        // Execution was terminated by SIGINT; ignore any callback that may have
        // fired as part of the termination.
        reject(new ERR_SCRIPT_EXECUTION_INTERRUPTED());
        return;
      }
      if (fired) {
        if (cbError) {
          reject(cbError);
        } else {
          resolve(cbResult);
        }
        return;
      }
      // The evaluation is still pending (e.g. top-level await); resolve once
      // the inspector eventually invokes the callback.
      onLate = () => {
        if (cbError) {
          reject(cbError);
        } else {
          resolve(cbResult);
        }
      };
    });
  }

  // Performs a `Runtime.evaluate`.
  async evaluate(params, breakOnSigint) {
    const response = await this.postInterruptible(
      'Runtime.evaluate', params, breakOnSigint);
    if (response.exceptionDetails || response.result?.subtype !== 'promise') {
      return response;
    }

    // Hold the promise open until it resolves or rejects,
    // so we can return the final value.
    return this.postInterruptible('Runtime.awaitPromise', {
      __proto__: null,
      promiseObjectId: response.result.objectId,
      returnByValue: params.returnByValue,
      generatePreview: params.generatePreview,
    }, breakOnSigint);
  }

  // Turns a remote object returned by the inspector into the real, live value
  // it points at.
  async toLiveValue(remote, context) {
    if (remote.objectId !== undefined) {
      const response = await this.post('Runtime.callFunctionOn', {
        functionDeclaration: kStashFn,
        objectId: remote.objectId,
        objectGroup: this.objectGroup,
      });
      if (response.exceptionDetails) {
        throw new ERR_INSPECTOR_NOT_AVAILABLE();
      }
      const value = context[kStashKey].value;
      context[kStashKey].value = undefined;
      return { __proto__: null, value };
    }
    if (remote.type === 'undefined') {
      return { __proto__: null, value: undefined };
    }
    if (ReflectHas(remote, 'value')) {
      return { __proto__: null, value: remote.value };
    }
    if (remote.unserializableValue !== undefined) {
      return { __proto__: null, value: fromUnserializable(remote.unserializableValue) };
    }
    return { __proto__: null, value: undefined };
  }

  // Releases every remote handle created in the shared object group.
  releaseObjects() {
    if (this.session !== null) {
      this.session.post('Runtime.releaseObjectGroup', {
        objectGroup: this.objectGroup,
      });
    }
  }

  // Fetches the global lexical scope names (`let`/`const`/`class` declarations)
  // for a context.
  globalLexicalScopeNames(contextId) {
    return PromisePrototypeThen(
      this.post('Runtime.globalLexicalScopeNames', {
        executionContextId: contextId,
      }),
      (result) => result?.names ?? [],
      () => [],
    );
  }

  // Captures the inspector execution-context id of a freshly created context.
  captureExecutionContextId(createContext) {
    const session = this.getSession();
    let contextId;
    session.once('Runtime.executionContextCreated', ({ params }) => {
      contextId = params.context.id;
    });
    const context = createContext();
    return { context, contextId };
  }
}

function getReplInspector(repl) {
  if (!hasInspector) {
    throw new ERR_INSPECTOR_NOT_AVAILABLE();
  }
  if (repl[kChannel] === undefined) {
    ObjectDefineProperty(repl, kChannel, {
      __proto__: null,
      value: new ReplInspectorChannel(repl),
      configurable: true,
      writable: true,
      enumerable: false,
    });
  }
  return repl[kChannel];
}

module.exports = {
  getReplInspector,
  prepareContext,
};