diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index 191ca0423fef..1da7b7bdf75a 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -151,17 +151,18 @@ export default function ( return; } - if (isNodeInspector) { - // node:inspector's inspector.open(): connections speak the V8 Chrome - // DevTools Protocol, the listening URL is reported back to the inspected - // thread (which prints Node's "Debugger listening on ..." line), and a - // control callback lets the inspected thread close the server or forward - // commands from the in-process inspector.Session. - let debug: Debugger | undefined; + // Control channel between the inspected thread and the debugger thread for + // a server node:inspector owns: inspector.close() stops it, a later + // inspector.open() starts a new one here instead of spawning another + // debugger thread, and an in-process inspector.Session forwards its + // Debugger.* commands to the backend a remote frontend shares. + // `initial` is the server that is already listening, if any. + 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); @@ -257,8 +258,17 @@ export default function ( return; } } - }; + } + return control; + } + if (isNodeInspector) { + // node:inspector's inspector.open(): connections speak the V8 Chrome + // DevTools Protocol, the listening URL is reported back to the inspected + // thread (which prints Node's "Debugger listening on ..." line), and a + // control callback lets the inspected thread close the server or forward + // commands from the in-process inspector.Session. + let debug: Debugger | undefined; try { debug = new Debugger( executionContextId, @@ -275,11 +285,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; } @@ -300,6 +314,14 @@ export default function ( exit("Failed to start inspector:\n", error); } + // --inspect serves a CDP endpoint alongside Bun's JSC one. Report it so + // node:inspector answers url() with it, refuses inspector.open() with + // ERR_INSPECTOR_ALREADY_ACTIVATED and can stop the server through + // inspector.close(), the way Node behaves for a CLI-started inspector. + if (enableNodeCDP && debug.cdpUrl) { + reportNodeInspectorServerStarted(debug.cdpUrl, createNodeInspectorControl(debug), undefined); + } + // If the user types --inspect, we print the URL to the console. // If the user is using an editor extension, don't print anything. if (!isAutomatic) { diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index 264c5e3114f8..717f9028ceb7 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -60,12 +60,21 @@ const drainInProcessInspectorMessages = $newCppFunction( ); const disconnectInProcessInspector = $newCppFunction("BunDebugger.cpp", "jsFunction_disconnectInProcessInspector", 0); const closeNodeInspector = $newCppFunction("BunDebugger.cpp", "jsFunction_closeNodeInspector", 0); +const getNodeInspectorUrl = $newCppFunction("BunDebugger.cpp", "jsFunction_getNodeInspectorUrl", 0); +// Emits one console message to attached inspector sessions only; see +// `console` below. +const inspectorConsoleCall = $newCppFunction("BunDebugger.cpp", "jsFunction_inspectorConsoleCall", 1); // Captured at module load so the console-hook stack capture keeps working // (and stays safe) after user code replaces or freezes globalThis.Error. const ErrorObject = globalThis.Error; const errorCaptureStackTrace = ErrorObject.captureStackTrace; +// Set only by open(): the debugger thread then owns a backend that this +// thread's Sessions must share, which is what the Debugger.* forwarding in +// #handleMethod keys off. A server started by --inspect is *listening* but +// owns no such backend, so url()/close()/waitForDebugger() consult +// getNodeInspectorUrl() instead of this. let activeInspectorUrl: string | undefined; // Same check as Node's internal/net.js isLoopback(). @@ -79,14 +88,23 @@ function isLoopbackHost(host: string) { ); } +// The URL of the node-inspector server this thread has listening, from either +// inspector.open() or --inspect. The server's state is owned by the main +// thread, and a worker cannot start one of its own (open() rejects there), so +// a worker never has a URL. +function listeningInspectorUrl(): string | undefined { + if (!Bun.isMainThread) return undefined; + return getNodeInspectorUrl() ?? undefined; +} + function open(port?: number, host?: string, wait?: boolean) { - if (activeInspectorUrl !== undefined) { - throw $ERR_INSPECTOR_ALREADY_ACTIVATED(); - } if (!Bun.isMainThread) { // Node supports per-worker inspectors; Bun does not yet. throw $ERR_WORKER_UNSUPPORTED_OPERATION("inspector.open() is not supported in workers"); } + if (listeningInspectorUrl() !== undefined) { + throw $ERR_INSPECTOR_ALREADY_ACTIVATED(); + } if (port !== undefined && port !== null) { if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) { @@ -159,7 +177,7 @@ function open(port?: number, host?: string, wait?: boolean) { } function close() { - if (activeInspectorUrl === undefined) { + if (listeningInspectorUrl() === undefined) { return; } // Sends the "close" control message and blocks until the debugger thread has @@ -170,11 +188,11 @@ function close() { function url() { // https://nodejs.org/api/inspector.html#inspectorurl - return activeInspectorUrl; + return listeningInspectorUrl(); } function waitForDebugger() { - if (activeInspectorUrl === undefined) { + if (listeningInspectorUrl() === undefined) { throw $ERR_INSPECTOR_NOT_ACTIVE(); } waitForNodeInspectorConnection(); @@ -1082,7 +1100,6 @@ class Session extends EventEmitter { // Resolvers for in-flight in-process commands, keyed by client command id. #pendingResults: Map void> = new SafeMap(); #nextCommandId = 1; - #dispatchingClientCommand = false; // Lazily route this session's untranslated commands through the CDP<->JSC // adapter and the in-process native channel; replies land in @@ -1128,13 +1145,10 @@ class Session extends EventEmitter { } } - // Command replies (messages with an id) produced during a post() dispatch - // are delivered after the dispatch unwinds, so a throwing callback can - // neither be misread by the adapter as a command failure nor run before - // post() returns. Events (messages with a method) always deliver - // immediately — a Debugger.paused from a pause nested inside a dispatch - // must reach listeners before execution continues, and #onClientMessage - // already contains listener throws. + // Delivered synchronously, replies included: Node dispatches into V8 from + // post() and the reply reaches the callback before post() returns (verified + // on v26.3.0). #onClientMessage turns a throwing callback into a process + // warning, so nothing escapes into the adapter's dispatch. #deliverClientMessage(clientMessage: string) { let parsed; try { @@ -1142,11 +1156,7 @@ class Session extends EventEmitter { } catch { return; } - if (this.#dispatchingClientCommand && parsed?.id !== undefined) { - queueMicrotask(this.#onClientMessage.bind(this, parsed)); - } else { - this.#onClientMessage(parsed); - } + this.#onClientMessage(parsed); } // Streams a V8-format heap snapshot to this session as @@ -1166,15 +1176,7 @@ class Session extends EventEmitter { const id = this.#nextCommandId++; this.#pendingResults.set(id, done); const message = JSON.stringify(params === undefined ? { id, method } : { id, method, params }); - const wasDispatching = this.#dispatchingClientCommand; - this.#dispatchingClientCommand = true; - try { - adapter.handleClientMessage(message); - } finally { - // Restore rather than clear: a post() re-entered from a listener must not - // flip the outer dispatch back to synchronous delivery. - this.#dispatchingClientCommand = wasDispatching; - } + adapter.handleClientMessage(message); } connect() { @@ -1243,13 +1245,9 @@ class Session extends EventEmitter { } if (callback !== undefined) validateFunction(callback, "callback"); + // Node throws here even when a callback was given (verified on v26.3.0). if (!this.#connected) { - const error = $ERR_INSPECTOR_NOT_CONNECTED(); - if (callback) { - queueMicrotask(callback.bind(undefined, error)); - return; - } - throw error; + throw $ERR_INSPECTOR_NOT_CONNECTED(); } let result = this.#handleMethod(method, params as object | undefined); @@ -1270,7 +1268,13 @@ class Session extends EventEmitter { } if (callback) { - queueMicrotask(settleLocalPost.bind(undefined, callback, result)); + // Same contract as a reply from the backend: synchronous, and a throw + // from the callback becomes a process warning rather than escaping. + try { + settleLocalPost(callback, result); + } catch (thrown) { + process.emitWarning(toWarning(thrown)); + } } // Node's post() always returns undefined, and without a callback a // protocol error is neither thrown nor otherwise observable (verified on @@ -1521,6 +1525,36 @@ class Session extends EventEmitter { case "NodeWorker.detach": return {}; + // The categories Node's tracing agent knows about, in the order it + // lists them. Bun's trace_events emits a subset; a client picks from + // this list either way. + // https://github.com/nodejs/node/blob/v26.3.0/src/inspector/tracing_agent.cc#L181-L206 + case "NodeTracing.getCategories": + return { + categories: [ + "node", + "node.async_hooks", + "node.bootstrap", + "node.console", + "node.dns.native", + "node.environment", + "node.fs.async", + "node.fs.sync", + "node.fs_dir.async", + "node.fs_dir.sync", + "node.http", + "node.net.native", + "node.perf", + "node.perf.timerify", + "node.perf.usertiming", + "node.promises.rejections", + "node.threadpoolwork.async", + "node.threadpoolwork.sync", + "node.vm.script", + "v8", + ], + }; + case "NodeTracing.start": { if (!Bun.isMainThread) { return { @@ -1574,8 +1608,68 @@ class Session extends EventEmitter { } } +// Node's `inspector.console` is V8's inspector console: each call becomes a +// Runtime.consoleAPICalled notification for attached sessions and nothing is +// written to stdout or stderr. Ids match Bun::InspectorConsoleMethod in +// src/jsc/bindings/BunDebugger.h. +const InspectorConsoleMethod = { + log: 0, + info: 1, + debug: 2, + warn: 3, + error: 4, + dir: 5, + dirxml: 6, + table: 7, + trace: 8, + clear: 9, + assert: 10, + group: 11, + groupCollapsed: 12, + groupEnd: 13, + count: 14, + countReset: 15, + profile: 16, + profileEnd: 17, + time: 18, + timeLog: 19, + timeEnd: 20, + timeStamp: 21, +}; + +function makeInspectorConsoleMethod(name: string) { + const id = InspectorConsoleMethod[name]; + const method = function (...args: unknown[]) { + inspectorConsoleCall(id, ...args); + }; + Object.defineProperty(method, "name", { __proto__: null, value: name, configurable: true }); + return method; +} + +// Same keys, in the same order, as Node v26.3.0's `inspector.console`. const console = { - ...globalThis.console, + debug: makeInspectorConsoleMethod("debug"), + error: makeInspectorConsoleMethod("error"), + info: makeInspectorConsoleMethod("info"), + log: makeInspectorConsoleMethod("log"), + warn: makeInspectorConsoleMethod("warn"), + dir: makeInspectorConsoleMethod("dir"), + dirxml: makeInspectorConsoleMethod("dirxml"), + table: makeInspectorConsoleMethod("table"), + trace: makeInspectorConsoleMethod("trace"), + group: makeInspectorConsoleMethod("group"), + groupCollapsed: makeInspectorConsoleMethod("groupCollapsed"), + groupEnd: makeInspectorConsoleMethod("groupEnd"), + clear: makeInspectorConsoleMethod("clear"), + count: makeInspectorConsoleMethod("count"), + countReset: makeInspectorConsoleMethod("countReset"), + assert: makeInspectorConsoleMethod("assert"), + profile: makeInspectorConsoleMethod("profile"), + profileEnd: makeInspectorConsoleMethod("profileEnd"), + time: makeInspectorConsoleMethod("time"), + timeLog: makeInspectorConsoleMethod("timeLog"), + timeEnd: makeInspectorConsoleMethod("timeEnd"), + timeStamp: makeInspectorConsoleMethod("timeStamp"), context: { console: globalThis.console, }, diff --git a/src/jsc/Debugger.rs b/src/jsc/Debugger.rs index 0dbe98be741a..c32c5590fa79 100644 --- a/src/jsc/Debugger.rs +++ b/src/jsc/Debugger.rs @@ -787,6 +787,15 @@ pub fn debug_end() { } } +/// `process.binding('inspector').isEnabled()`: whether this thread has an +/// inspector agent that `process._debugEnd()` has not stopped. +// HOST_EXPORT(Debugger__isEnabled, c) +pub fn is_enabled() -> bool { + VirtualMachine::get() + .debugger_mut() + .is_some_and(|dbg| !dbg.debug_ended) +} + // HOST_EXPORT(Debugger__didConnect, c) pub fn did_connect() { let this = VirtualMachine::get().as_mut(); diff --git a/src/jsc/bindings/BunCPUProfiler.cpp b/src/jsc/bindings/BunCPUProfiler.cpp index 978ab5660494..dcd0fccd7f32 100644 --- a/src/jsc/bindings/BunCPUProfiler.cpp +++ b/src/jsc/bindings/BunCPUProfiler.cpp @@ -360,11 +360,42 @@ void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) double startTime = s_profilingStartTime; double lastTime = s_profilingStartTime; + // V8 accounts for the wall-clock time the thread spent outside JS with + // (idle) samples, which Node drives from the event loop's idle + // callback. JSC's sampling profiler records nothing at all then, so + // reconstruct them: a hole longer than one extra sampling period means + // no JS ran across it. + int idleNodeId = 0; + auto appendIdleSampleBefore = [&](double sampleTime) { + double gap = sampleTime - lastTime; + if (gap <= 2.0 * s_samplingInterval) + return; + if (!idleNodeId) { + idleNodeId = nextNodeId++; + ProfileNode idleNode; + idleNode.id = idleNodeId; + idleNode.functionName = "(idle)"_s; + idleNode.url = ""_s; + idleNode.scriptId = 0; + idleNode.lineNumber = -1; + idleNode.columnNumber = -1; + idleNode.hitCount = 0; + nodes.append(WTF::move(idleNode)); + nodes[0].children.append(idleNodeId); + } + nodes[idleNodeId - 1].hitCount++; + samples.append(idleNodeId); + // The sample that follows keeps one sampling period of its own. + timeDeltas.append(static_cast(gap - s_samplingInterval)); + lastTime = sampleTime - s_samplingInterval; + }; + for (size_t idx : sortedIndices) { auto& stackTrace = stackTraces[idx]; + double currentTime = stackTrace.timestamp.approximate().secondsSinceEpoch().value() * 1000000.0; + appendIdleSampleBefore(currentTime); if (stackTrace.frames.isEmpty()) { samples.append(1); - double currentTime = stackTrace.timestamp.approximate().secondsSinceEpoch().value() * 1000000.0; double delta = std::max(0.0, currentTime - lastTime); timeDeltas.append(static_cast(delta)); lastTime = currentTime; @@ -538,7 +569,6 @@ void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) samples.append(currentParentId); - double currentTime = stackTrace.timestamp.approximate().secondsSinceEpoch().value() * 1000000.0; double delta = std::max(0.0, currentTime - lastTime); timeDeltas.append(static_cast(delta)); lastTime = currentTime; diff --git a/src/jsc/bindings/BunDebugger.cpp b/src/jsc/bindings/BunDebugger.cpp index e3d2130549cf..abc670398aa7 100644 --- a/src/jsc/bindings/BunDebugger.cpp +++ b/src/jsc/bindings/BunDebugger.cpp @@ -15,6 +15,8 @@ #include "debug-helpers.h" #include "BunInjectedScriptHost.h" #include +#include +#include #include #include "InspectorLifecycleAgent.h" @@ -1232,6 +1234,23 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_waitForNodeInspectorConnection, (JSGlobalObj return JSValue::encode(jsUndefined()); } +// The listening node-inspector server's URL, or null when none is listening. +// node:inspector cannot cache this: --inspect starts the server on the +// debugger thread before the module is ever required. +JSC_DEFINE_HOST_FUNCTION(jsFunction_getNodeInspectorUrl, (JSGlobalObject * globalObject, CallFrame*)) +{ + auto& vm = JSC::getVM(globalObject); + auto& state = nodeInspectorState(); + String url; + { + Locker locker(state.lock); + if (!state.serverStarted || state.url.isEmpty()) + return JSValue::encode(jsNull()); + url = state.url.isolatedCopy(); + } + return JSValue::encode(jsString(vm, url)); +} + // Dispatches one JSC-protocol message from the in-process node:inspector // Session against this realm's inspector controller, synchronously on the // calling JS thread, and returns every message the backend produced for the @@ -1359,6 +1378,143 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_disconnectInProcessInspector, (JSGlobalObjec return JSValue::encode(jsUndefined()); } +// node:inspector's `inspector.console`. V8 routes those calls to the inspector +// only, so nothing reaches stdout or stderr; Bun::ConsoleObject writes the +// terminal output *and* forwards to the inspector controller's own console +// client, so calling that client directly is the inspector-only half. +JSC_DEFINE_HOST_FUNCTION(jsFunction_inspectorConsoleCall, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + uint32_t rawMethod = callFrame->argument(0).toUInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (rawMethod > kLastInspectorConsoleMethod) { + throwTypeError(globalObject, scope, "invalid inspector console method"_s); + return {}; + } + auto method = static_cast(rawMethod); + + // Every argument is coerced before the console client is reached, because + // a `toString` can run user JS that disconnects the frontend. + String label; + size_t firstValueArgument = 1; + switch (method) { + case InspectorConsoleMethod::Count: + case InspectorConsoleMethod::CountReset: + case InspectorConsoleMethod::Time: + case InspectorConsoleMethod::TimeLog: + case InspectorConsoleMethod::TimeEnd: + // JSC's console spells the omitted label this way too. + label = callFrame->argument(1).isUndefined() ? "default"_s : callFrame->argument(1).toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + firstValueArgument = 2; + break; + case InspectorConsoleMethod::Profile: + case InspectorConsoleMethod::ProfileEnd: + label = callFrame->argument(1).isUndefined() ? String() : callFrame->argument(1).toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + firstValueArgument = 2; + break; + case InspectorConsoleMethod::Assert: { + bool condition = callFrame->argument(1).toBoolean(globalObject); + // console.assert only reports when the assertion failed. + if (condition) + return JSValue::encode(jsUndefined()); + firstValueArgument = 2; + break; + } + default: + break; + } + + if (!globalObject->inspectable()) + return JSValue::encode(jsUndefined()); + auto client = globalObject->inspectorController().consoleClient(); + if (!client) + return JSValue::encode(jsUndefined()); + + Vector> values; + size_t argumentCount = callFrame->argumentCount(); + if (argumentCount > firstValueArgument) { + values.reserveInitialCapacity(argumentCount - firstValueArgument); + for (size_t i = firstValueArgument; i < argumentCount; i++) + values.append(JSC::Strong(vm, callFrame->uncheckedArgument(i))); + } + Ref arguments = Inspector::ScriptArguments::create(globalObject, WTF::move(values)); + + switch (method) { + case InspectorConsoleMethod::Log: + client->logWithLevel(globalObject, WTF::move(arguments), MessageLevel::Log); + break; + case InspectorConsoleMethod::Info: + client->logWithLevel(globalObject, WTF::move(arguments), MessageLevel::Info); + break; + case InspectorConsoleMethod::Debug: + client->logWithLevel(globalObject, WTF::move(arguments), MessageLevel::Debug); + break; + case InspectorConsoleMethod::Warn: + client->logWithLevel(globalObject, WTF::move(arguments), MessageLevel::Warning); + break; + case InspectorConsoleMethod::Error: + client->logWithLevel(globalObject, WTF::move(arguments), MessageLevel::Error); + break; + case InspectorConsoleMethod::Dir: + client->dir(globalObject, WTF::move(arguments)); + break; + case InspectorConsoleMethod::DirXML: + client->dirXML(globalObject, WTF::move(arguments)); + break; + case InspectorConsoleMethod::Table: + client->table(globalObject, WTF::move(arguments)); + break; + case InspectorConsoleMethod::Trace: + client->trace(globalObject, WTF::move(arguments)); + break; + case InspectorConsoleMethod::Clear: + client->clear(globalObject); + break; + case InspectorConsoleMethod::Assert: + client->assertion(globalObject, WTF::move(arguments)); + break; + case InspectorConsoleMethod::Group: + client->group(globalObject, WTF::move(arguments)); + break; + case InspectorConsoleMethod::GroupCollapsed: + client->groupCollapsed(globalObject, WTF::move(arguments)); + break; + case InspectorConsoleMethod::GroupEnd: + client->groupEnd(globalObject, WTF::move(arguments)); + break; + case InspectorConsoleMethod::Count: + client->count(globalObject, label); + break; + case InspectorConsoleMethod::CountReset: + client->countReset(globalObject, label); + break; + case InspectorConsoleMethod::Profile: + client->profile(globalObject, label); + break; + case InspectorConsoleMethod::ProfileEnd: + client->profileEnd(globalObject, label); + break; + case InspectorConsoleMethod::Time: + client->time(globalObject, label); + break; + case InspectorConsoleMethod::TimeLog: + client->timeLog(globalObject, label, WTF::move(arguments)); + break; + case InspectorConsoleMethod::TimeEnd: + client->timeEnd(globalObject, label); + break; + case InspectorConsoleMethod::TimeStamp: + client->timeStamp(globalObject, WTF::move(arguments)); + break; + } + + return JSValue::encode(jsUndefined()); +} + // Forwards a control message (close, breakpoint forwarded from the in-process // Session, ...) from the main thread to the node-inspector server running on // the debugger thread. Returns false when no server is active. diff --git a/src/jsc/bindings/BunDebugger.h b/src/jsc/bindings/BunDebugger.h index 574410927509..6828006c3050 100644 --- a/src/jsc/bindings/BunDebugger.h +++ b/src/jsc/bindings/BunDebugger.h @@ -14,5 +14,36 @@ JSC_DECLARE_HOST_FUNCTION(jsFunction_closeNodeInspector); JSC_DECLARE_HOST_FUNCTION(jsFunction_dispatchInProcessInspectorMessage); JSC_DECLARE_HOST_FUNCTION(jsFunction_drainInProcessInspectorMessages); JSC_DECLARE_HOST_FUNCTION(jsFunction_disconnectInProcessInspector); +JSC_DECLARE_HOST_FUNCTION(jsFunction_getNodeInspectorUrl); + +// The methods of node:inspector's `inspector.console`, mirrored by +// InspectorConsoleMethod in src/js/node/inspector.ts. Keep both in sync. +enum class InspectorConsoleMethod : uint32_t { + Log = 0, + Info, + Debug, + Warn, + Error, + Dir, + DirXML, + Table, + Trace, + Clear, + Assert, + Group, + GroupCollapsed, + GroupEnd, + Count, + CountReset, + Profile, + ProfileEnd, + Time, + TimeLog, + TimeEnd, + TimeStamp, +}; +static constexpr uint32_t kLastInspectorConsoleMethod = static_cast(InspectorConsoleMethod::TimeStamp); + +JSC_DECLARE_HOST_FUNCTION(jsFunction_inspectorConsoleCall); } // namespace Bun diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 6bf928774a38..9fe2065b762e 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3305,6 +3305,23 @@ inline JSValue processBindingConfig(Zig::GlobalObject* globalObject, JSC::VM& vm return config; } +extern "C" bool Debugger__isEnabled(); + +JSC_DEFINE_HOST_FUNCTION(jsFunctionInspectorIsEnabled, (JSGlobalObject*, CallFrame*)) +{ + return JSValue::encode(jsBoolean(Debugger__isEnabled())); +} + +// Only `isEnabled` is exposed: the rest of Node's internal inspector binding +// is reachable as public `node:inspector` API, and a stub that answered for +// methods Bun does not implement would be worse than the missing property. +inline JSValue processBindingInspector(Zig::GlobalObject* globalObject, JSC::VM& vm) +{ + auto* binding = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 1); + binding->putDirect(vm, JSC::Identifier::fromString(vm, "isEnabled"_s), JSC::JSFunction::create(vm, globalObject, 0, String("isEnabled"_s), jsFunctionInspectorIsEnabled, ImplementationVisibility::Public), 0); + return binding; +} + JSValue createCryptoX509Object(JSGlobalObject* globalObject) { auto& vm = JSC::getVM(globalObject); @@ -3334,7 +3351,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionBinding, (JSGlobalObject * jsGlobalObje if (moduleName == "fs_event_wrap"_s) PROCESS_BINDING_NOT_IMPLEMENTED("fs_event_wrap"); if (moduleName == "http_parser"_s) return JSValue::encode(globalObject->processBindingHTTPParser()); if (moduleName == "icu"_s) PROCESS_BINDING_NOT_IMPLEMENTED("icu"); - if (moduleName == "inspector"_s) PROCESS_BINDING_NOT_IMPLEMENTED("inspector"); + if (moduleName == "inspector"_s) return JSValue::encode(processBindingInspector(globalObject, vm)); if (moduleName == "js_stream"_s) PROCESS_BINDING_NOT_IMPLEMENTED("js_stream"); if (moduleName == "natives"_s) return JSValue::encode(process->bindingNatives()); if (moduleName == "os"_s) PROCESS_BINDING_NOT_IMPLEMENTED("os"); diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 5ffd35eb2819..9548f25c3922 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -304,6 +304,10 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!("--trace-exit"), parse_param!("--expose-internals"), parse_param!("--stack-trace-limit "), + // Rejected below with Node's DEP0062 message. Declared so `--debug=` + // does not swallow the entrypoint before the rejection runs. + parse_param!("--debug ?"), + parse_param!("--debug-brk ?"), ]; pub(crate) const AUTO_OR_RUN_PARAMS: &[ParamType] = &[ @@ -1143,6 +1147,18 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result { expect(() => session.post("Profiler.enable")).toThrow("not connected"); }); - test("post() with callback calls callback with error if not connected", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); - session.post("Profiler.enable", err => { - if (err) resolve(err); - else reject(new Error("Expected error")); - }); - const error = await promise; - expect(error.message).toContain("not connected"); + test("post() with a callback still throws if not connected", () => { + // Node checks the connection before it looks at the callback, so the + // error is thrown rather than delivered (verified on v26.3.0). + const callback = jest.fn(); + expect(() => session.post("Profiler.enable", callback)).toThrow( + expect.objectContaining({ code: "ERR_INSPECTOR_NOT_CONNECTED" }), + ); + expect(callback).not.toHaveBeenCalled(); }); }); @@ -639,7 +639,52 @@ console.log(JSON.stringify({ first: countFor(first), second: countFor(second) }) test("console is exported", () => { expect(inspector.console).toBeObject(); - expect(inspector.console.log).toBe(globalThis.console.log); + // Node's inspector console is not the global console: it reports to the + // inspector only, so nothing reaches stdout or stderr. + expect(inspector.console.log).toBeInstanceOf(Function); + expect(inspector.console.log).not.toBe(globalThis.console.log); + expect(Object.keys(inspector.console)).toEqual([ + "debug", + "error", + "info", + "log", + "warn", + "dir", + "dirxml", + "table", + "trace", + "group", + "groupCollapsed", + "groupEnd", + "clear", + "count", + "countReset", + "assert", + "profile", + "profileEnd", + "time", + "timeLog", + "timeEnd", + "timeStamp", + "context", + ]); + }); + + test("console.log writes nothing to stdout or stderr", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `require("node:inspector").console.log("from the inspector console"); console.log("from the global console");`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("from the global console\n"); + expect(stderr).not.toContain("from the inspector console"); + expect(exitCode).toBe(0); }); // open()/close()/waitForDebugger() behavior is covered in inspector.test.ts; diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index b191a4ed15ab..dfcd3ecacfef 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -381,7 +381,10 @@ it("process.binding", () => { expect(() => process.binding("fs_event_wrap")).toThrow(); expect(() => process.binding("http_parser")).not.toThrow(); expect(() => process.binding("icu")).toThrow(); - expect(() => process.binding("inspector")).toThrow(); + // Only isEnabled() is implemented; test-inspector-enabled.js covers what it + // reports under --inspect and after process._debugEnd(). + expect(() => process.binding("inspector")).not.toThrow(); + expect(process.binding("inspector").isEnabled).toBeInstanceOf(Function); expect(() => process.binding("js_stream")).toThrow(); expect(() => process.binding("natives")).not.toThrow(); expect(() => process.binding("os")).toThrow(); diff --git a/test/js/node/test/parallel/test-inspector-already-activated-cli.js b/test/js/node/test/parallel/test-inspector-already-activated-cli.js new file mode 100644 index 000000000000..9de226cedca6 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-already-activated-cli.js @@ -0,0 +1,24 @@ +// Flags: --inspect=0 +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const { isMainThread } = require('worker_threads'); + +if (!isMainThread) { + common.skip('This test only works on a main thread'); +} + +const assert = require('assert'); +const inspector = require('inspector'); +const wsUrl = inspector.url(); +assert(wsUrl.startsWith('ws://')); +assert.throws(() => { + inspector.open(0, undefined, false); +}, { + code: 'ERR_INSPECTOR_ALREADY_ACTIVATED' +}); +assert.strictEqual(inspector.url(), wsUrl); +inspector.close(); +assert.strictEqual(inspector.url(), undefined); diff --git a/test/js/node/test/parallel/test-inspector-async-stack-traces-promise-then.js b/test/js/node/test/parallel/test-inspector-async-stack-traces-promise-then.js new file mode 100644 index 000000000000..188f38b8ef45 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-async-stack-traces-promise-then.js @@ -0,0 +1,74 @@ +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); +common.skipIf32Bits(); +const { NodeInstance } = require('../common/inspector-helper'); +const assert = require('assert'); + +const script = `runTest(); +function runTest() { + const p = Promise.resolve(); + p.then(function break1() { // lineNumber 3 + debugger; + }); + p.then(function break2() { // lineNumber 6 + debugger; + }); +} +`; + +async function runTests() { + const instance = new NodeInstance(undefined, script); + const session = await instance.connectInspectorSession(); + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send([ + { 'method': 'Runtime.enable' }, + { 'method': 'Debugger.enable' }, + { 'method': 'Debugger.setAsyncCallStackDepth', + 'params': { 'maxDepth': 10 } }, + { 'method': 'Debugger.setBlackboxPatterns', + 'params': { 'patterns': [] } }, + { 'method': 'Runtime.runIfWaitingForDebugger' }, + ]); + await session.send({ method: 'NodeRuntime.disable' }); + + await session.waitForBreakOnLine(0, '[eval]'); + await session.send({ 'method': 'Debugger.resume' }); + + console.error('[test] Waiting for break1'); + debuggerPausedAt(await session.waitForBreakOnLine(4, '[eval]'), + 'break1', 'runTest:3'); + + await session.send({ 'method': 'Debugger.resume' }); + + console.error('[test] Waiting for break2'); + debuggerPausedAt(await session.waitForBreakOnLine(7, '[eval]'), + 'break2', 'runTest:6'); + + await session.runToCompletion(); + assert.strictEqual((await instance.expectShutdown()).exitCode, 0); +} + +function debuggerPausedAt(msg, functionName, previousTickLocation) { + assert( + !!msg.params.asyncStackTrace, + `${Object.keys(msg.params)} contains "asyncStackTrace" property`); + + assert.strictEqual(msg.params.callFrames[0].functionName, functionName); + assert.strictEqual(msg.params.asyncStackTrace.description, 'Promise.then'); + + const frameLocations = msg.params.asyncStackTrace.callFrames.map( + (frame) => `${frame.functionName}:${frame.lineNumber}`); + assertArrayIncludes(frameLocations, previousTickLocation); +} + +function assertArrayIncludes(actual, expected) { + const expectedString = JSON.stringify(expected); + const actualString = JSON.stringify(actual); + assert( + actual.includes(expected), + `Expected ${actualString} to contain ${expectedString}.`); +} + +runTests().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-break-e.js b/test/js/node/test/parallel/test-inspector-break-e.js new file mode 100644 index 000000000000..ccbef3134041 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-break-e.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { NodeInstance } = require('../common/inspector-helper.js'); + +async function runTests() { + const instance = new NodeInstance(undefined, 'console.log(10)'); + const session = await instance.connectInspectorSession(); + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send([ + { 'method': 'Runtime.enable' }, + { 'method': 'Debugger.enable' }, + { 'method': 'Runtime.runIfWaitingForDebugger' }, + ]); + await session.send({ method: 'NodeRuntime.disable' }); + await session.waitForBreakOnLine(0, '[eval]'); + await session.runToCompletion(); + assert.strictEqual((await instance.expectShutdown()).exitCode, 0); +} + +runTests().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-break-when-eval.js b/test/js/node/test/parallel/test-inspector-break-when-eval.js new file mode 100644 index 000000000000..6fc76acd840c --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-break-when-eval.js @@ -0,0 +1,80 @@ +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); +const assert = require('assert'); +const { NodeInstance } = require('../common/inspector-helper.js'); +const fixtures = require('../common/fixtures'); +const { pathToFileURL } = require('url'); + +// This needs to be an ES module file to ensure that internal modules are +// loaded before pausing. See +// https://bugs.chromium.org/p/chromium/issues/detail?id=1246905 +const script = fixtures.path('inspector-global-function.mjs'); + +async function setupDebugger(session) { + console.log('[test]', 'Setting up a debugger'); + const commands = [ + { 'method': 'Runtime.enable' }, + { 'method': 'Debugger.enable' }, + { 'method': 'Debugger.setAsyncCallStackDepth', + 'params': { 'maxDepth': 0 } }, + { 'method': 'Runtime.runIfWaitingForDebugger' }, + ]; + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send(commands); + await session.send({ method: 'NodeRuntime.disable' }); + + await session.waitForNotification('Debugger.paused', 'Initial pause'); + + // NOTE(mmarchini): We wait for the second console.log to ensure we loaded + // every internal module before pausing. See + // https://bugs.chromium.org/p/chromium/issues/detail?id=1246905 + const waitForReady = session.waitForConsoleOutput('log', 'Ready!'); + session.send({ 'method': 'Debugger.resume' }); + await waitForReady; +} + +async function breakOnLine(session) { + console.log('[test]', 'Breaking in the code'); + const commands = [ + { 'method': 'Debugger.setBreakpointByUrl', + 'params': { 'lineNumber': 9, + 'url': pathToFileURL(script).toString(), + 'columnNumber': 0, + 'condition': '' } }, + { 'method': 'Runtime.evaluate', + 'params': { 'expression': 'sum()', + 'objectGroup': 'console', + 'includeCommandLineAPI': true, + 'silent': false, + 'contextId': 1, + 'returnByValue': false, + 'generatePreview': true, + 'userGesture': true, + 'awaitPromise': false } }, + ]; + session.send(commands); + await session.waitForBreakOnLine(9, pathToFileURL(script).toString()); +} + +async function stepOverConsoleStatement(session) { + console.log('[test]', 'Step over console statement and test output'); + session.send({ 'method': 'Debugger.stepOver' }); + await session.waitForConsoleOutput('log', [0, 3]); + await session.waitForNotification('Debugger.paused'); +} + +async function runTests() { + // NOTE(mmarchini): Use --inspect-brk to improve avoid indeterministic + // behavior. + const child = new NodeInstance(['--inspect-brk=0'], undefined, script); + const session = await child.connectInspectorSession(); + await setupDebugger(session); + await breakOnLine(session); + await stepOverConsoleStatement(session); + await session.runToCompletion(); + assert.strictEqual((await child.expectShutdown()).exitCode, 0); +} + +runTests().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-console.js b/test/js/node/test/parallel/test-inspector-console.js new file mode 100644 index 000000000000..659eccc17a71 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-console.js @@ -0,0 +1,40 @@ +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const { NodeInstance } = require('../common/inspector-helper.js'); +const assert = require('assert'); + +async function runTest() { + const script = 'require(\'inspector\').console.log(\'hello world\');'; + const child = new NodeInstance('--inspect-brk=0', script, ''); + + let out = ''; + child.on('stdout', (line) => out += line); + + const session = await child.connectInspectorSession(); + + const commands = [ + { 'method': 'Runtime.enable' }, + { 'method': 'Runtime.runIfWaitingForDebugger' }, + ]; + + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send(commands); + await session.send({ method: 'NodeRuntime.disable' }); + + const msg = await session.waitForNotification('Runtime.consoleAPICalled'); + + assert.strictEqual(msg.params.type, 'log'); + assert.deepStrictEqual(msg.params.args, [{ + type: 'string', + value: 'hello world' + }]); + assert.strictEqual(out, ''); + + session.disconnect(); +} + +runTest().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-debug-brk-flag.js b/test/js/node/test/parallel/test-inspector-debug-brk-flag.js new file mode 100644 index 000000000000..e417f54e93bf --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-debug-brk-flag.js @@ -0,0 +1,42 @@ +'use strict'; +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { NodeInstance } = require('../common/inspector-helper.js'); + +async function testBreakpointOnStart(session) { + const commands = [ + { 'method': 'Runtime.enable' }, + { 'method': 'Debugger.enable' }, + { 'method': 'Debugger.setPauseOnExceptions', + 'params': { 'state': 'none' } }, + { 'method': 'Debugger.setAsyncCallStackDepth', + 'params': { 'maxDepth': 0 } }, + { 'method': 'Profiler.enable' }, + { 'method': 'Profiler.setSamplingInterval', + 'params': { 'interval': 100 } }, + { 'method': 'Debugger.setBlackboxPatterns', + 'params': { 'patterns': [] } }, + { 'method': 'Runtime.runIfWaitingForDebugger' }, + ]; + + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send(commands); + await session.send({ method: 'NodeRuntime.disable' }); + await session.waitForBreakOnLine(0, session.scriptURL()); +} + +async function runTests() { + const child = new NodeInstance(['--inspect-brk=0']); + const session = await child.connectInspectorSession(); + + await testBreakpointOnStart(session); + await session.runToCompletion(); + + assert.strictEqual((await child.expectShutdown()).exitCode, 55); +} + +runTests().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-debug-end.js b/test/js/node/test/parallel/test-inspector-debug-end.js new file mode 100644 index 000000000000..328f15628e3c --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-debug-end.js @@ -0,0 +1,46 @@ +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); +const assert = require('assert'); +const { NodeInstance } = require('../common/inspector-helper.js'); + +async function testNoServerNoCrash() { + console.log('Test there\'s no crash stopping server that was not started'); + const instance = new NodeInstance([], + `process._debugEnd(); + process.exit(42);`); + assert.strictEqual((await instance.expectShutdown()).exitCode, 42); +} + +async function testNoSessionNoCrash() { + console.log('Test there\'s no crash stopping server without connecting'); + const instance = new NodeInstance('--inspect=0', + 'process._debugEnd();process.exit(42);'); + assert.strictEqual((await instance.expectShutdown()).exitCode, 42); +} + +async function testSessionNoCrash() { + console.log('Test there\'s no crash stopping server after connecting'); + const script = `process._debugEnd(); + process._debugProcess(process.pid); + setTimeout(() => { + console.log("Done"); + process.exit(42); + });`; + + const instance = new NodeInstance('--inspect-brk=0', script); + const session = await instance.connectInspectorSession(); + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send({ 'method': 'Runtime.runIfWaitingForDebugger' }); + await session.waitForServerDisconnect(); + assert.strictEqual((await instance.expectShutdown()).exitCode, 42); +} + +async function runTest() { + await testNoServerNoCrash(); + await testNoSessionNoCrash(); + await testSessionNoCrash(); +} + +runTest().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-enabled.js b/test/js/node/test/parallel/test-inspector-enabled.js new file mode 100644 index 000000000000..33140ba5074f --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-enabled.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const spawn = require('child_process').spawn; + +const script = ` +const assert = require('assert'); +const inspector = process.binding('inspector'); + +assert( + !!inspector.isEnabled(), + 'inspector.isEnabled() should be true when run with --inspect'); + +process._debugEnd(); + +assert( + !inspector.isEnabled(), + 'inspector.isEnabled() should be false after _debugEnd()'); +`; + +const args = ['--inspect=0', '-e', script]; +const child = spawn(process.execPath, args, { + stdio: 'inherit', + env: { ...process.env, NODE_V8_COVERAGE: '' } +}); +child.on('exit', (code, signal) => { + process.exit(code || signal); +}); diff --git a/test/js/node/test/parallel/test-inspector-exception.js b/test/js/node/test/parallel/test-inspector-exception.js new file mode 100644 index 000000000000..fdfc6bab2989 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-exception.js @@ -0,0 +1,47 @@ +'use strict'; +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { NodeInstance } = require('../common/inspector-helper.js'); +const { pathToFileURL } = require('url'); + +const script = fixtures.path('throws_error.js'); + +async function testBreakpointOnStart(session) { + console.log('[test]', + 'Verifying debugger stops on start (--inspect-brk option)'); + const commands = [ + { 'method': 'Runtime.enable' }, + { 'method': 'Debugger.enable' }, + { 'method': 'Debugger.setPauseOnExceptions', + 'params': { 'state': 'none' } }, + { 'method': 'Debugger.setAsyncCallStackDepth', + 'params': { 'maxDepth': 0 } }, + { 'method': 'Profiler.enable' }, + { 'method': 'Profiler.setSamplingInterval', + 'params': { 'interval': 100 } }, + { 'method': 'Debugger.setBlackboxPatterns', + 'params': { 'patterns': [] } }, + { 'method': 'Runtime.runIfWaitingForDebugger' }, + ]; + + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send(commands); + await session.send({ method: 'NodeRuntime.disable' }); + await session.waitForBreakOnLine(21, pathToFileURL(script).toString()); +} + + +async function runTest() { + const child = new NodeInstance(undefined, undefined, script); + const session = await child.connectInspectorSession(); + await testBreakpointOnStart(session); + await session.runToCompletion(); + assert.strictEqual((await child.expectShutdown()).exitCode, 1); +} + +runTest().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-has-idle.js b/test/js/node/test/parallel/test-inspector-has-idle.js new file mode 100644 index 000000000000..c14590353e67 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-has-idle.js @@ -0,0 +1,43 @@ +'use strict'; +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { Session } = require('inspector'); +const { promisify } = require('util'); + +const sleep = promisify(setTimeout); + +async function test() { + const inspector = new Session(); + inspector.connect(); + + inspector.post('Profiler.enable'); + inspector.post('Profiler.start'); + + await sleep(1000); + + const { profile } = await new Promise((resolve, reject) => { + inspector.post('Profiler.stop', (err, params) => { + if (err) return reject(err); + resolve(params); + }); + }); + + let hasIdle = false; + for (const node of profile.nodes) { + if (node.callFrame.functionName === '(idle)') { + hasIdle = true; + break; + } + } + assert(hasIdle); + + inspector.post('Profiler.disable'); + inspector.disconnect(); +} + +test().then(common.mustCall(() => { + console.log('Done!'); +})); diff --git a/test/js/node/test/parallel/test-inspector-heap-allocation-tracker.js b/test/js/node/test/parallel/test-inspector-heap-allocation-tracker.js new file mode 100644 index 000000000000..4002d15b3c16 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-heap-allocation-tracker.js @@ -0,0 +1,46 @@ +'use strict'; +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const inspector = require('inspector'); +const stream = require('stream'); +const { Worker, workerData } = require('worker_threads'); + +const session = new inspector.Session(); +session.connect(); +session.post('HeapProfiler.enable'); +session.post('HeapProfiler.startTrackingHeapObjects', + { trackAllocations: true }); + +// Perform some silly heap allocations for the next 100 ms. +const interval = setInterval(common.mustCallAtLeast(() => { + new stream.PassThrough().end('abc').on('data', common.mustCall()); +}), 1); + +setTimeout(common.mustCall(() => { + clearInterval(interval); + + // Once the main test is done, we re-run it from inside a Worker thread + // and stop early, as that is a good way to make sure the timer handles + // internally created by the inspector are cleaned up properly. + if (workerData === 'stopEarly') + process.exit(); + + let data = ''; + session.on('HeapProfiler.addHeapSnapshotChunk', + common.mustCallAtLeast((event) => { + data += event.params.chunk; + })); + + // TODO(addaleax): Using `{ reportProgress: true }` crashes the process + // because the progress indication event would mean calling into JS while + // a heap snapshot is being taken, which is forbidden. + // What can we do about that? + session.post('HeapProfiler.stopTrackingHeapObjects'); + + assert(data.includes('PassThrough'), data); + + new Worker(__filename, { workerData: 'stopEarly' }); +}), 100); diff --git a/test/js/node/test/parallel/test-inspector-invalid-args.js b/test/js/node/test/parallel/test-inspector-invalid-args.js new file mode 100644 index 000000000000..846a46a429ff --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-invalid-args.js @@ -0,0 +1,27 @@ +'use strict'; +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const execFile = require('child_process').execFile; + +const mainScript = fixtures.path('loop.js'); +const expected = + '`node --debug` and `node --debug-brk` are invalid. ' + + 'Please use `node --inspect` and `node --inspect-brk` instead.'; +for (const invalidArg of ['--debug-brk', '--debug']) { + execFile( + process.execPath, + [invalidArg, mainScript], + common.mustCall((error, stdout, stderr) => { + assert.strictEqual(error.code, 9, `node ${invalidArg} should exit 9`); + assert.strictEqual( + stderr.includes(expected), + true, + `${stderr} should include '${expected}'` + ); + }) + ); +} diff --git a/test/js/node/test/parallel/test-inspector-module.js b/test/js/node/test/parallel/test-inspector-module.js new file mode 100644 index 000000000000..cdbbc69229dd --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-module.js @@ -0,0 +1,71 @@ +'use strict'; + +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { Session } = require('inspector'); + +const session = new Session(); + +assert.throws( + () => session.post('Runtime.evaluate', { expression: '2 + 2' }), + { + code: 'ERR_INSPECTOR_NOT_CONNECTED', + name: 'Error', + message: 'Session is not connected' + } +); + +session.connect(); +session.post('Runtime.evaluate', { expression: '2 + 2' }); + +[1, {}, [], true, Infinity, undefined].forEach((i) => { + assert.throws( + () => session.post(i), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: + 'The "method" argument must be of type string.' + + common.invalidArgTypeHelper(i) + } + ); +}); + +[1, true, Infinity].forEach((i) => { + assert.throws( + () => session.post('test', i), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: + 'The "params" argument must be of type object.' + + common.invalidArgTypeHelper(i) + } + ); +}); + +[1, 'a', {}, [], true, Infinity].forEach((i) => { + assert.throws( + () => session.post('test', {}, i), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + } + ); +}); + +assert.throws( + () => session.connect(), + { + code: 'ERR_INSPECTOR_ALREADY_CONNECTED', + name: 'Error', + message: 'The inspector session is already connected' + } +); + +session.disconnect(); +// Calling disconnect twice should not throw. +session.disconnect(); diff --git a/test/js/node/test/parallel/test-inspector-multisession-ws.js b/test/js/node/test/parallel/test-inspector-multisession-ws.js new file mode 100644 index 000000000000..6a2b1951d27e --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-multisession-ws.js @@ -0,0 +1,85 @@ +'use strict'; +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +const { NodeInstance } = require('../common/inspector-helper.js'); + +// Sets up JS bindings session and runs till the "paused" event +const script = ` +const { Session } = require('inspector'); +const session = new Session(); +let done = false; +const interval = setInterval(() => { + if (done) + clearInterval(interval); +}, 150); +session.on('Debugger.paused', () => { + done = true; +}); +session.connect(); +session.post('Debugger.enable'); +console.log('Ready'); +`; + +async function setupSession(node) { + const session = await node.connectInspectorSession(); + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send([ + { 'method': 'Runtime.enable' }, + { 'method': 'Debugger.enable' }, + { 'method': 'Debugger.setPauseOnExceptions', + 'params': { 'state': 'none' } }, + { 'method': 'Debugger.setAsyncCallStackDepth', + 'params': { 'maxDepth': 0 } }, + { 'method': 'Profiler.enable' }, + { 'method': 'Profiler.setSamplingInterval', + 'params': { 'interval': 100 } }, + { 'method': 'Debugger.setBlackboxPatterns', + 'params': { 'patterns': [] } }, + ]); + + return session; +} + +async function testSuspend(sessionA, sessionB) { + console.log('[test]', 'Breaking in code and verifying events are fired'); + await Promise.all([ + sessionA.waitForNotification('Debugger.paused', 'Initial sessionA paused'), + sessionB.waitForNotification('Debugger.paused', 'Initial sessionB paused'), + ]); + sessionA.send({ 'method': 'Debugger.resume' }); + + await sessionA.waitForNotification('Runtime.consoleAPICalled', + 'Console output'); + sessionA.send({ 'method': 'Debugger.pause' }); + return Promise.all([ + sessionA.waitForNotification('Debugger.paused', 'SessionA paused'), + sessionB.waitForNotification('Debugger.paused', 'SessionB paused'), + ]); +} + +async function runTest() { + const child = new NodeInstance(undefined, script); + + const [session1, session2] = + await Promise.all([setupSession(child), setupSession(child)]); + await Promise.all([ + session1.send({ method: 'Runtime.runIfWaitingForDebugger' }), + session2.send({ method: 'Runtime.runIfWaitingForDebugger' }), + ]); + await Promise.all([ + session1.send({ method: 'NodeRuntime.disable' }), + session2.send({ method: 'NodeRuntime.disable' }), + ]); + await testSuspend(session2, session1); + console.log('[test]', 'Should shut down after both sessions disconnect'); + + await session1.runToCompletion(); + await session2.send({ 'method': 'Debugger.disable' }); + await session2.disconnect(); + return child.expectShutdown(); +} + +runTest().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-stop-profile-after-done.js b/test/js/node/test/parallel/test-inspector-stop-profile-after-done.js new file mode 100644 index 000000000000..8378496753a6 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-stop-profile-after-done.js @@ -0,0 +1,33 @@ +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); +const assert = require('assert'); +const { NodeInstance } = require('../common/inspector-helper.js'); + +async function runTests() { + const child = new NodeInstance(['--inspect-brk=0'], + `let c = 0; + const interval = setInterval(() => { + console.log(new Object()); + if (c++ === 10) + clearInterval(interval); + }, ${common.platformTimeout(30)});`); + const session = await child.connectInspectorSession(); + + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send([ + { method: 'Profiler.setSamplingInterval', + params: { interval: common.platformTimeout(300) } }, + { method: 'Profiler.enable' }]); + await session.send({ method: 'Runtime.runIfWaitingForDebugger' }); + await session.send({ method: 'NodeRuntime.disable' }); + await session.send({ method: 'Profiler.start' }); + while (await child.nextStderrString() !== + 'Waiting for the debugger to disconnect...'); + await session.send({ method: 'Profiler.stop' }); + session.disconnect(); + assert.strictEqual((await child.expectShutdown()).exitCode, 0); +} + +runTests().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-tracing-domain.js b/test/js/node/test/parallel/test-inspector-tracing-domain.js new file mode 100644 index 000000000000..aa31d63a0157 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-tracing-domain.js @@ -0,0 +1,92 @@ +'use strict'; + +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +const { isMainThread } = require('worker_threads'); + +if (!isMainThread) { + // https://github.com/nodejs/node/issues/22767 + common.skip('This test only works on a main thread'); +} + +const assert = require('assert'); +const { Session } = require('inspector'); + +const session = new Session(); + +function post(message, data) { + return new Promise((resolve, reject) => { + session.post(message, data, (err, result) => { + if (err) + reject(new Error(JSON.stringify(err))); + else + resolve(result); + }); + }); +} + +function generateTrace() { + return new Promise((resolve) => setTimeout(() => { + for (let i = 0; i < 1000000; i++) { + 'test' + i; // eslint-disable-line no-unused-expressions + } + resolve(); + }, 1)); +} + +async function test() { + // This interval ensures Node does not terminate till the test is finished. + // Inspector session does not keep the node process running (e.g. it does not + // have async handles on the main event loop). It is debatable whether this + // should be considered a bug, and there are no plans to fix it atm. + const interval = setInterval(() => {}, 5000); + session.connect(); + let traceNotification = null; + let tracingComplete = false; + session.on('NodeTracing.dataCollected', (n) => traceNotification = n); + session.on('NodeTracing.tracingComplete', () => tracingComplete = true); + const { categories } = await post('NodeTracing.getCategories'); + const expectedCategories = [ + 'node', + 'node.async_hooks', + 'node.bootstrap', + 'node.console', + 'node.dns.native', + 'node.environment', + 'node.fs.async', + 'node.fs.sync', + 'node.fs_dir.async', + 'node.fs_dir.sync', + 'node.http', + 'node.net.native', + 'node.perf', + 'node.perf.timerify', + 'node.perf.usertiming', + 'node.promises.rejections', + 'node.threadpoolwork.async', + 'node.threadpoolwork.sync', + 'node.vm.script', + 'v8', + ].sort(); + assert.ok(categories.length === expectedCategories.length); + categories.forEach((category, index) => { + const value = expectedCategories[index]; + assert.ok(category === value, `${category} is out of order, expect ${value}`); + }); + + const traceConfig = { includedCategories: ['v8'] }; + await post('NodeTracing.start', { traceConfig }); + + for (let i = 0; i < 5; i++) + await generateTrace(); + JSON.stringify(await post('NodeTracing.stop', { traceConfig })); + session.disconnect(); + assert(traceNotification.params.value.length > 0); + assert(tracingComplete); + clearInterval(interval); + console.log('Success'); +} + +test().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-wait-for-connection.js b/test/js/node/test/parallel/test-inspector-wait-for-connection.js new file mode 100644 index 000000000000..c28bebb1daa1 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-wait-for-connection.js @@ -0,0 +1,78 @@ +'use strict'; +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { NodeInstance } = require('../common/inspector-helper.js'); + +async function runTests() { + const child = new NodeInstance(['-e', `(${main.toString()})()`], '', ''); + const session = await child.connectInspectorSession(); + await session.send({ method: 'Runtime.enable' }); + // Check that there is only one console message received. + await session.waitForConsoleOutput('log', 'before wait for debugger'); + assert.ok(!session.unprocessedNotifications() + .some((n) => n.method === 'Runtime.consoleAPICalled')); + // Check that inspector.url() is available between inspector.open() and + // inspector.waitForDebugger() + const { result: { value } } = await session.send({ + method: 'Runtime.evaluate', + params: { + expression: 'process._ws', + includeCommandLineAPI: true + } + }); + assert.ok(value.startsWith('ws://')); + await session.send({ method: 'NodeRuntime.enable' }); + child.write('first'); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send({ method: 'Runtime.runIfWaitingForDebugger' }); + await session.send({ method: 'NodeRuntime.disable' }); + // Check that messages after first and before second waitForDebugger are + // received + await session.waitForConsoleOutput('log', 'after wait for debugger'); + await session.waitForConsoleOutput('log', 'before second wait for debugger'); + assert.ok(!session.unprocessedNotifications() + .some((n) => n.method === 'Runtime.consoleAPICalled')); + const secondSession = await child.connectInspectorSession(); + // Check that inspector.waitForDebugger can be resumed from another session + await session.send({ method: 'NodeRuntime.enable' }); + child.write('second'); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send({ method: 'Runtime.runIfWaitingForDebugger' }); + await session.send({ method: 'NodeRuntime.disable' }); + await session.waitForConsoleOutput('log', 'after second wait for debugger'); + assert.ok(!session.unprocessedNotifications() + .some((n) => n.method === 'Runtime.consoleAPICalled')); + secondSession.disconnect(); + session.disconnect(); + + function main(prefix) { + const inspector = require('inspector'); + inspector.open(0, undefined, false); + process._ws = inspector.url(); + console.log('before wait for debugger'); + process.stdin.once('data', (data) => { + if (data.toString() === 'first') { + inspector.waitForDebugger(); + console.log('after wait for debugger'); + console.log('before second wait for debugger'); + process.stdin.once('data', (data) => { + if (data.toString() === 'second') { + inspector.waitForDebugger(); + console.log('after second wait for debugger'); + process.exit(); + } + }); + } + }); + } + + // Check that inspector.waitForDebugger throws if there is no active + // inspector + const re = /^Error \[ERR_INSPECTOR_NOT_ACTIVE\]: Inspector is not active$/; + assert.throws(() => require('inspector').waitForDebugger(), re); +} + +runTests().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-wait.mjs b/test/js/node/test/parallel/test-inspector-wait.mjs new file mode 100644 index 000000000000..9bb28ed22b25 --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-wait.mjs @@ -0,0 +1,28 @@ +import * as common from '../common/index.mjs'; + +common.skipIfInspectorDisabled(); + +import assert from 'node:assert'; +import { NodeInstance } from '../common/inspector-helper.js'; + + +async function runTests() { + const child = new NodeInstance(['--inspect-wait=0'], 'console.log(0);'); + const session = await child.connectInspectorSession(); + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + + // The execution should be paused until the debugger is attached + while (await child.nextStderrString() !== 'Debugger attached.'); + + await session.send({ 'method': 'Runtime.runIfWaitingForDebugger' }); + + // Wait for the execution to finish + while (await child.nextStderrString() !== 'Waiting for the debugger to disconnect...'); + + await session.send({ method: 'NodeRuntime.disable' }); + session.disconnect(); + assert.strictEqual((await child.expectShutdown()).exitCode, 0); +} + +runTests().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-inspector-waiting-for-disconnect.js b/test/js/node/test/parallel/test-inspector-waiting-for-disconnect.js new file mode 100644 index 000000000000..7c4ca7ec7cdd --- /dev/null +++ b/test/js/node/test/parallel/test-inspector-waiting-for-disconnect.js @@ -0,0 +1,47 @@ +'use strict'; +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { NodeInstance } = require('../common/inspector-helper.js'); + +function mainContextDestroyed(notification) { + return notification.method === 'Runtime.executionContextDestroyed' && + notification.params.executionContextId === 1; +} + +async function runTest() { + const child = new NodeInstance(['--inspect-brk=0', '-e', 'process.exit(55)']); + const session = await child.connectInspectorSession(); + const oldStyleSession = await child.connectInspectorSession(); + await oldStyleSession.send([ + { method: 'Runtime.enable' }]); + await session.send({ method: 'NodeRuntime.enable' }); + await session.waitForNotification('NodeRuntime.waitingForDebugger'); + await session.send([ + { method: 'Runtime.enable' }, + { method: 'NodeRuntime.notifyWhenWaitingForDisconnect', + params: { enabled: true } }, + { method: 'Runtime.runIfWaitingForDebugger' }]); + await session.send({ method: 'NodeRuntime.disable' }); + await session.waitForNotification((notification) => { + return notification.method === 'NodeRuntime.waitingForDisconnect'; + }); + const receivedExecutionContextDestroyed = + session.unprocessedNotifications().some(mainContextDestroyed); + if (receivedExecutionContextDestroyed) { + assert.fail('When NodeRuntime enabled, ' + + 'Runtime.executionContextDestroyed should not be sent'); + } + const { result: { value } } = await session.send({ + method: 'Runtime.evaluate', params: { expression: '42' } + }); + assert.strictEqual(value, 42); + await session.disconnect(); + await oldStyleSession.waitForNotification(mainContextDestroyed); + await oldStyleSession.disconnect(); + assert.strictEqual((await child.expectShutdown()).exitCode, 55); +} + +runTest().then(common.mustCall());