diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index 2488a5995b9..8b9246fd3ca 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -146,14 +146,15 @@ export default function ( return; } - if (isNodeInspector) { - // inspector.open(): CDP connections, URL reported back, control callback for close/forward. - // https://github.com/nodejs/node/blob/main/lib/inspector.js - let debug: Debugger | undefined; + // Control channel from the inspected thread to a node:inspector-owned + // server: close() stops it, open() restarts it here, and an in-process + // Session forwards Debugger.* to the shared backend. `initial` may be nil. + function createNodeInspectorControl(initial: Debugger | undefined) { + let debug = initial; let sessionBackend: Backend | undefined; let sessionAdapter: any; let sessionRefs = 0; - const control = (message: string) => { + function control(message: string) { let parsed: any; try { parsed = JSON.parse(message); @@ -246,8 +247,15 @@ export default function ( return; } } - }; + } + return control; + } + if (isNodeInspector) { + // node:inspector's inspector.open(): serve CDP, report the URL back (for + // Node's "Debugger listening on ..." line), and hand back a control + // callback so the inspected thread can close the server / forward commands. + let debug: Debugger | undefined; try { debug = new Debugger( executionContextId, @@ -264,11 +272,15 @@ export default function ( // Register the control callback even though the server failed to start // (e.g. the port is in use), so a later inspector.open() can retry with // an "open" control message on this already-running debugger thread. - reportNodeInspectorServerStarted("", control, nodeInspectorListenErrorDetail(error)); + reportNodeInspectorServerStarted( + "", + createNodeInspectorControl(undefined), + nodeInspectorListenErrorDetail(error), + ); return; } - reportNodeInspectorServerStarted(debug.url!.href, control, undefined); + reportNodeInspectorServerStarted(debug.url!.href, createNodeInspectorControl(debug), undefined); return; } @@ -289,8 +301,11 @@ export default function ( exit("Failed to start inspector:\n", error); } - // If the user types --inspect, we print the URL to the console. - // If the user is using an editor extension, don't print anything. + const { cdpUrl } = debug; + + // Print the URL for --inspect (not for editor extensions), *before* + // reportNodeInspectorServerStarted releases the inspected thread: Node's + // banner precedes script output and stderr-scraping tools rely on that order. if (!isAutomatic) { const debugUrl = debug.url; if (debugUrl) { @@ -319,6 +334,13 @@ export default function ( } } + // Report --inspect's CDP endpoint so node:inspector's url()/open()/close() + // behave as Node does for a CLI-started inspector; this also releases the + // inspected thread, which blocks on the report. + if (enableNodeCDP && cdpUrl) { + reportNodeInspectorServerStarted(cdpUrl, createNodeInspectorControl(debug), undefined); + } + const notifyUrl = process.env["BUN_INSPECT_NOTIFY"] || ""; if (notifyUrl) { // Only send this once. diff --git a/src/js/internal/debugger/inspect.ts b/src/js/internal/debugger/inspect.ts new file mode 100644 index 00000000000..c5ca01f067d --- /dev/null +++ b/src/js/internal/debugger/inspect.ts @@ -0,0 +1,362 @@ +// Port of Node.js lib/internal/debugger/inspect.js (v26.3.0) — the entry +// point for the `bun inspect` / `node inspect` CLI debugger. +"use strict"; + +const { + ArrayPrototypeForEach, + ArrayPrototypeJoin, + ArrayPrototypeMap, + ArrayPrototypePop, + ArrayPrototypeShift, + ArrayPrototypeSlice, + FunctionPrototypeBind, + Number, + PromisePrototypeThen, + PromiseResolve, + Proxy, + RegExpPrototypeExec, + RegExpPrototypeSymbolSplit, + StringPrototypeEndsWith, + StringPrototypeSplit, +} = require("internal/debugger/primordials"); + +const { EventEmitter } = require("node:events"); +const util = require("node:util"); +const { setTimeout: pSetTimeout } = require("node:timers/promises"); + +const InspectClient = require("internal/debugger/inspect_client"); +const { launchChildProcess, writeInspectUsageAndExit } = require("internal/debugger/inspect_helpers"); +const { parseProbeTokens, runProbeMode } = require("internal/debugger/inspect_probe"); +const createRepl = require("internal/debugger/inspect_repl"); + +const debuglog = util.debuglog("inspect"); + +// Node's process exit codes (internalBinding('errors').exitCodes upstream). +const kGenericUserError = 1; +const kInvalidCommandLineArgument = 9; +const kNoFailure = 0; + +function createAgentProxy(domain, client) { + const agent = new EventEmitter(); + agent.then = (then, _catch) => { + // TODO: potentially fetch the protocol and pretty-print it here. + const descriptor = { + [util.inspect.custom](depth, { stylize }) { + return stylize(`[Agent ${domain}]`, "special"); + }, + }; + return PromisePrototypeThen(PromiseResolve(descriptor), then, _catch); + }; + + return new Proxy(agent, { + __proto__: null, + get(target, name) { + if (name in target) return target[name]; + return function callVirtualMethod(params) { + return client.callMethod(`${domain}.${name}`, params); + }; + }, + }); +} + +class NodeInspector { + constructor(options, stdin, stdout) { + this.options = options; + this.stdin = stdin; + this.stdout = stdout; + + this.paused = true; + this.child = null; + + const { script } = options; + if (script) { + this._runScript = FunctionPrototypeBind( + launchChildProcess, + null, + [script, ...options.scriptArgs], + options.host, + options.port, + FunctionPrototypeBind(this.childPrint, this), + { __proto__: null, deferBreakToClient: true }, + ); + } else { + this._runScript = () => PromiseResolve([null, options.port, options.host]); + } + + this.client = new InspectClient(); + + this.domainNames = ["Debugger", "HeapProfiler", "Profiler", "Runtime"]; + ArrayPrototypeForEach(this.domainNames, domain => { + this[domain] = createAgentProxy(domain, this.client); + }); + this.handleDebugEvent = (fullName, params) => { + const { 0: domain, 1: name } = StringPrototypeSplit(fullName, ".", 2); + if (domain in this) { + this[domain].emit(name, params); + } + }; + this.client.on("debugEvent", this.handleDebugEvent); + const startRepl = createRepl(this); + + // Handle all possible exits + process.on("exit", () => this.killChild()); + const exitCodeZero = () => process.exit(kNoFailure); + process.once("SIGTERM", exitCodeZero); + process.once("SIGHUP", exitCodeZero); + + (async () => { + try { + await this.run(); + const repl = await startRepl(); + this.repl = repl; + this.repl.on("exit", exitCodeZero); + this.paused = false; + } catch (error) { + process.nextTick(() => { + throw error; + }); + } + })(); + } + + suspendReplWhile(fn) { + const { repl } = this; + if (repl) { + repl.pause(); + } + this.stdin.pause(); + this.paused = true; + return (async () => { + try { + await fn(); + this.paused = false; + if (repl) { + repl.resume(); + repl.displayPrompt(); + } + this.stdin.resume(); + } catch (error) { + process.nextTick(() => { + throw error; + }); + } + })(); + } + + killChild() { + this.client.reset(); + if (this.child) { + this.child.kill(); + this.child = null; + } + } + + async run() { + this.killChild(); + + const { 0: child, 1: port, 2: host } = await this._runScript(); + this.child = child; + + this.print(`connecting to ${host}:${port} ..`, false); + for (let attempt = 0; attempt < 5; attempt++) { + debuglog("connection attempt #%d", attempt); + this.stdout.write("."); + try { + await this.client.connect(port, host); + debuglog("connection established"); + if (this.options.script) { + // See launchChildProcess: the child is parked in --inspect-wait, so + // arm the break on its first statement before the REPL resumes it. + await this.client.callMethod("Debugger.enable"); + await this.client.callMethod("Debugger.pause"); + } + this.stdout.write(" ok\n"); + return; + } catch (error) { + debuglog("connect failed", error); + await pSetTimeout(1000); + } + } + this.stdout.write(" failed to connect, please retry\n"); + process.exit(kGenericUserError); + } + + clearLine() { + if (this.stdout.isTTY) { + this.stdout.cursorTo(0); + this.stdout.clearLine(1); + } else { + this.stdout.write("\b"); + } + } + + print(text, appendNewline = false) { + this.clearLine(); + this.stdout.write(appendNewline ? `${text}\n` : text); + } + + #stdioBuffers = { stdout: "", stderr: "" }; + childPrint(text, which) { + const lines = RegExpPrototypeSymbolSplit(/\r\n|\r|\n/g, this.#stdioBuffers[which] + text); + + this.#stdioBuffers[which] = ""; + + if (lines[lines.length - 1] !== "") { + this.#stdioBuffers[which] = ArrayPrototypePop(lines); + } + + const textToPrint = ArrayPrototypeJoin( + ArrayPrototypeMap(lines, chunk => `< ${chunk}`), + "\n", + ); + + if (lines.length) { + this.print(textToPrint, true); + if (!this.paused) { + this.repl.displayPrompt(true); + } + } + + if (StringPrototypeEndsWith(textToPrint, "Waiting for the debugger to disconnect...\n")) { + this.killChild(); + } + } +} + +function parseInteractiveArgs(args) { + const target = ArrayPrototypeShift(args); + let host = "127.0.0.1"; + let port = 9229; + let isRemote = false; + let script = target; + let scriptArgs = args; + + const hostMatch = RegExpPrototypeExec(/^([^:]+):(\d+)$/, target); + const portMatch = RegExpPrototypeExec(/^--port=(\d+)$/, target); + + if (hostMatch) { + // Connecting to remote debugger + host = hostMatch[1]; + port = Number(hostMatch[2]); + isRemote = true; + script = null; + } else if (portMatch) { + // Start on custom port + port = Number(portMatch[1]); + script = args[0]; + scriptArgs = ArrayPrototypeSlice(args, 1); + } else if (args.length === 1 && RegExpPrototypeExec(/^\d+$/, args[0]) !== null && target === "-p") { + // Start debugger against a given pid + const pid = Number(args[0]); + try { + process._debugProcess(pid); + } catch (e) { + if (e.code === "ESRCH") { + process.stderr.write(`Target process: ${pid} doesn't exist.\n`); + process.exit(kGenericUserError); + } + throw e; + } + script = null; + isRemote = true; + } + + return { + host, + port, + isRemote, + script, + scriptArgs, + }; +} + +const kInspectArgOptions = { + __proto__: null, + expr: { type: "string" }, + help: { type: "boolean", short: "h" }, + json: { type: "boolean" }, + // Port and timeout use type 'string' because parseArgs has no + // numeric type; the values are parsed to integers by parseProbeTokens(). + port: { type: "string" }, + preview: { type: "boolean" }, + probe: { type: "string" }, + timeout: { type: "string" }, +}; + +// Returns { mode: 'help' | 'probe' | 'interactive', ... } for `inspect` args; +// the first option/terminator/positional token decides the mode. +function parseInspectMode(args) { + const { tokens } = util.parseArgs({ + args, + allowPositionals: true, + options: kInspectArgOptions, + strict: false, + tokens: true, + }); + + for (const token of tokens) { + if (token.kind === "option") { + if (token.name === "help") return { mode: "help" }; + if (token.name === "probe") { + // `--probe --help` / `--probe -h` (no value) consumes the help flag + // as the probe's "value"; surface help instead of a probe error. + if (!token.inlineValue && (token.value === "--help" || token.value === "-h")) { + return { mode: "help" }; + } + return { mode: "probe", tokens, args }; + } + } + if (token.kind === "option-terminator" || token.kind === "positional") { + break; + } + } + return { mode: "interactive" }; +} + +function startInspect(argv = ArrayPrototypeSlice(process.argv, 2), stdin = process.stdin, stdout = process.stdout) { + const invokedAs = `${process.argv0} ${process.argv[1]}`; + + if (argv.length < 1) { + writeInspectUsageAndExit(invokedAs, undefined, kInvalidCommandLineArgument); + } + + const parsed = parseInspectMode(argv); + + if (parsed.mode === "help") { + writeInspectUsageAndExit(invokedAs); + } + + if (parsed.mode === "probe") { + let probeOptions; + try { + probeOptions = parseProbeTokens(parsed.tokens, parsed.args); + } catch (error) { + writeInspectUsageAndExit(invokedAs, error.message, kInvalidCommandLineArgument); + } + runProbeMode(stdout, probeOptions); + return; + } + + const options = parseInteractiveArgs(argv); + const inspector = new NodeInspector(options, stdin, stdout); + + stdin.resume(); + + function handleUnexpectedError(e) { + if (e.code !== "ERR_DEBUGGER_STARTUP_ERROR") { + process.stderr.write( + "There was an internal error in Bun's debugger. Please report this bug.\n" + `${e.message}\n${e.stack}\n`, + ); + } else { + process.stderr.write(e.message); + process.stderr.write("\n"); + } + const { child } = inspector; + if (child) child.kill(); + process.exit(kGenericUserError); + } + + process.on("uncaughtException", handleUnexpectedError); +} + +export default { start: startInspect }; diff --git a/src/js/internal/debugger/inspect_client.ts b/src/js/internal/debugger/inspect_client.ts new file mode 100644 index 00000000000..d62d2f66b70 --- /dev/null +++ b/src/js/internal/debugger/inspect_client.ts @@ -0,0 +1,358 @@ +// Port of Node.js lib/internal/debugger/inspect_client.js (v26.3.0). +"use strict"; + +const { + ArrayPrototypeForEach, + ArrayPrototypePush, + ErrorCaptureStackTrace, + FunctionPrototypeBind, + JSONParse, + JSONStringify, + ObjectKeys, + ObjectValues, + Promise, +} = require("internal/debugger/primordials"); + +const { Buffer } = require("node:buffer"); +const crypto = require("node:crypto"); +const { EventEmitter, once } = require("node:events"); +const http = require("node:http"); + +const debuglog = require("node:util").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 = $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 $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 $ERR_DEBUGGER_ERROR("Compressed frames not supported"); + } + if (!final || reserved2 || reserved3) { + throw $ERR_DEBUGGER_ERROR("Only compression extension is supported"); + } + + if (masked) { + throw $ERR_DEBUGGER_ERROR("Masked server frame - not supported"); + } + + let closed = false; + switch (opCode) { + case kOpCodeClose: + closed = true; + break; + case kOpCodeText: + break; + default: + throw $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 $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 $ERR_DEBUGGER_ERROR(`Unsupported response: ${payloadStr}`); + } + } + } + + reset() { + const pending = this._pending; + if (pending) { + ArrayPrototypeForEach(ObjectValues(pending), handler => { + handler({ + code: "ERR_DEBUGGER_ERROR", + message: "Debugger session ended", + }); + }); + } + const { _http, _socket } = this; + if (_http) { + _http.destroy(); + } + if (_socket) { + _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($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(); + const { statusCode } = httpRes; + if (statusCode !== 200) { + reject($ERR_DEBUGGER_ERROR(`Unexpected ${statusCode}: ${resBody}`)); + return; + } + try { + resolve(JSONParse(resBody)); + } catch { + reject($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 => { + const { statusCode } = httpRes; + if (statusCode >= 400) { + process.stderr.write(`Unexpected HTTP code: ${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; + } +} + +export default Client; diff --git a/src/js/internal/debugger/inspect_helpers.ts b/src/js/internal/debugger/inspect_helpers.ts new file mode 100644 index 00000000000..74a2ea0013f --- /dev/null +++ b/src/js/internal/debugger/inspect_helpers.ts @@ -0,0 +1,185 @@ +// Port of Node.js lib/internal/debugger/inspect_helpers.js (v26.3.0) for the +// `bun inspect` / `node inspect` CLI debugger. +"use strict"; + +const { + ArrayPrototypePushApply, + Number, + Promise, + RegExpPrototypeExec, + StringPrototypeEndsWith, +} = require("internal/debugger/primordials"); + +const { spawn } = require("node:child_process"); +const net = require("node:net"); +const { setInterval: pSetInterval, setTimeout: pSetTimeout } = require("node:timers/promises"); + +// Node's exit code for an invalid command line argument. +const kInvalidCommandLineArgument = 9; + +function ERR_DEBUGGER_STARTUP_ERROR(message, options?) { + const err = $ERR_DEBUGGER_STARTUP_ERROR(message); + const childStderr = options?.childStderr; + if (childStderr !== undefined) { + err.childStderr = childStderr; + } + return err; +} + +const debugRegex = /Debugger listening on ws:\/\/\[?(.+?)\]?:(\d+)\//; + +async function portIsFree(host, port, timeout = 3000) { + if (port === 0) return; // Binding to a random port. + + const retryDelay = 150; + const ac = new AbortController(); + const { signal } = ac; + + pSetTimeout(timeout).then(() => ac.abort()); + + const asyncIterator = pSetInterval(retryDelay); + while (true) { + await asyncIterator.next(); + if (signal.aborted) { + throw ERR_DEBUGGER_STARTUP_ERROR(`Timeout (${timeout}) waiting for ${host}:${port} to be free`); + } + const error = await new Promise(resolve => { + const socket = net.connect(port, host); + socket.on("error", resolve); + socket.on("connect", () => { + socket.end(); + resolve(); + }); + }); + if (error?.code === "ECONNREFUSED") { + return; + } + } +} + +function ensureTrailingNewline(text) { + return StringPrototypeEndsWith(text, "\n") ? text : `${text}\n`; +} + +function writeInspectUsageAndExit(invokedAs, message?, exitCode?) { + const code = exitCode ?? (message ? kInvalidCommandLineArgument : 0); + const out = code === 0 ? process.stdout : process.stderr; + if (message) { + out.write(`${message}\n`); + } + out.write(`Usage: ${invokedAs} [--port=] [ ...] + [