diff --git a/src/bun.js/HardcodedModule.zig b/src/bun.js/HardcodedModule.zig index 698b400fb7..84f6ff9968 100644 --- a/src/bun.js/HardcodedModule.zig +++ b/src/bun.js/HardcodedModule.zig @@ -65,6 +65,7 @@ pub const HardcodedModule = enum { @"node:trace_events", @"node:repl", @"node:inspector", + @"node:inspector/promises", @"node:http2", @"node:diagnostics_channel", @"node:dgram", @@ -121,6 +122,7 @@ pub const HardcodedModule = enum { .{ "node:http2", .@"node:http2" }, .{ "node:https", .@"node:https" }, .{ "node:inspector", .@"node:inspector" }, + .{ "node:inspector/promises", .@"node:inspector/promises" }, .{ "node:module", .@"node:module" }, .{ "node:net", .@"node:net" }, .{ "node:readline", .@"node:readline" }, @@ -230,6 +232,7 @@ pub const HardcodedModule = enum { nodeEntry("node:http2"), nodeEntry("node:https"), nodeEntry("node:inspector"), + nodeEntry("node:inspector/promises"), nodeEntry("node:module"), nodeEntry("node:net"), nodeEntry("node:os"), @@ -285,6 +288,7 @@ pub const HardcodedModule = enum { nodeEntry("http2"), nodeEntry("https"), nodeEntry("inspector"), + nodeEntry("inspector/promises"), nodeEntry("module"), nodeEntry("net"), nodeEntry("os"), @@ -366,10 +370,6 @@ pub const HardcodedModule = enum { .{ "bun:internal-for-testing", .{ .path = "bun:internal-for-testing" } }, .{ "ffi", .{ .path = "bun:ffi" } }, - // inspector/promises is not implemented, it is an alias of inspector - .{ "node:inspector/promises", .{ .path = "node:inspector", .node_builtin = true } }, - .{ "inspector/promises", .{ .path = "node:inspector", .node_builtin = true } }, - // Thirdparty packages we override .{ "@vercel/fetch", .{ .path = "@vercel/fetch" } }, .{ "isomorphic-fetch", .{ .path = "isomorphic-fetch" } }, @@ -394,12 +394,7 @@ pub const HardcodedModule = enum { .{ "vitest", .{ .path = "bun:test" } }, }; - const node_extra_alias_kvs = [_]struct { string, Alias }{ - nodeEntry("node:inspector/promises"), - nodeEntry("inspector/promises"), - }; - - const node_aliases = bun.ComptimeStringMap(Alias, common_alias_kvs ++ node_extra_alias_kvs); + const node_aliases = bun.ComptimeStringMap(Alias, common_alias_kvs); pub const bun_aliases = bun.ComptimeStringMap(Alias, common_alias_kvs ++ bun_extra_alias_kvs); const bun_test_aliases = bun.ComptimeStringMap(Alias, common_alias_kvs ++ bun_extra_alias_kvs ++ bun_test_extra_alias_kvs); diff --git a/src/bun.js/bindings/BunCPUProfiler.cpp b/src/bun.js/bindings/BunCPUProfiler.cpp index 6fa40d2881..4038cb5ee4 100644 --- a/src/bun.js/bindings/BunCPUProfiler.cpp +++ b/src/bun.js/bindings/BunCPUProfiler.cpp @@ -23,6 +23,19 @@ namespace Bun { // Store the profiling start time in microseconds since Unix epoch static double s_profilingStartTime = 0.0; +// Set sampling interval to 1ms (1000 microseconds) to match Node.js +static int s_samplingInterval = 1000; +static bool s_isProfilerRunning = false; + +void setSamplingInterval(int intervalMicroseconds) +{ + s_samplingInterval = intervalMicroseconds; +} + +bool isCPUProfilerRunning() +{ + return s_isProfilerRunning; +} void startCPUProfiler(JSC::VM& vm) { @@ -35,12 +48,10 @@ void startCPUProfiler(JSC::VM& vm) stopwatch->start(); JSC::SamplingProfiler& samplingProfiler = vm.ensureSamplingProfiler(WTF::move(stopwatch)); - - // Set sampling interval to 1ms (1000 microseconds) to match Node.js - samplingProfiler.setTimingInterval(WTF::Seconds::fromMicroseconds(1000)); - + samplingProfiler.setTimingInterval(WTF::Seconds::fromMicroseconds(s_samplingInterval)); samplingProfiler.noticeCurrentThreadAsJSCExecutionThread(); samplingProfiler.start(); + s_isProfilerRunning = true; } struct ProfileNode { @@ -56,27 +67,29 @@ struct ProfileNode { WTF::String stopCPUProfilerAndGetJSON(JSC::VM& vm) { + s_isProfilerRunning = false; + JSC::SamplingProfiler* profiler = vm.samplingProfiler(); if (!profiler) return WTF::String(); - // Shut down the profiler thread first - this is critical! - profiler->shutdown(); - - // Need to hold the VM lock to safely access stack traces + // JSLock is re-entrant, so always acquiring it handles both JS and shutdown contexts JSC::JSLockHolder locker(vm); // Defer GC while we're working with stack traces JSC::DeferGC deferGC(vm); + // Pause the profiler while holding the lock - this is critical for thread safety. + // The sampling thread holds this lock while modifying traces, so holding it here + // ensures no concurrent modifications. We use pause() instead of shutdown() to + // allow the profiler to be restarted for the inspector API. auto& lock = profiler->getLock(); WTF::Locker profilerLocker { lock }; + profiler->pause(); // releaseStackTraces() calls processUnverifiedStackTraces() internally auto stackTraces = profiler->releaseStackTraces(); - - if (stackTraces.isEmpty()) - return WTF::String(); + profiler->clearData(); // Build Chrome CPU Profiler format // Map from stack frame signature to node ID diff --git a/src/bun.js/bindings/BunCPUProfiler.h b/src/bun.js/bindings/BunCPUProfiler.h index 4a7b9c739e..b5fd9f3fc2 100644 --- a/src/bun.js/bindings/BunCPUProfiler.h +++ b/src/bun.js/bindings/BunCPUProfiler.h @@ -10,11 +10,14 @@ class VM; namespace Bun { +void setSamplingInterval(int intervalMicroseconds); +bool isCPUProfilerRunning(); + // Start the CPU profiler void startCPUProfiler(JSC::VM& vm); // Stop the CPU profiler and convert to Chrome CPU profiler JSON format -// Returns JSON string, or empty string on failure +// Returns JSON string, or empty string if profiler was never started WTF::String stopCPUProfilerAndGetJSON(JSC::VM& vm); } // namespace Bun diff --git a/src/bun.js/bindings/JSInspectorProfiler.cpp b/src/bun.js/bindings/JSInspectorProfiler.cpp new file mode 100644 index 0000000000..1ec1541e6f --- /dev/null +++ b/src/bun.js/bindings/JSInspectorProfiler.cpp @@ -0,0 +1,49 @@ +#include "root.h" +#include "helpers.h" +#include "BunCPUProfiler.h" +#include "NodeValidator.h" +#include +#include +#include + +using namespace JSC; + +JSC_DECLARE_HOST_FUNCTION(jsFunction_startCPUProfiler); +JSC_DEFINE_HOST_FUNCTION(jsFunction_startCPUProfiler, (JSGlobalObject * globalObject, CallFrame*)) +{ + Bun::startCPUProfiler(globalObject->vm()); + return JSValue::encode(jsUndefined()); +} + +JSC_DECLARE_HOST_FUNCTION(jsFunction_stopCPUProfiler); +JSC_DEFINE_HOST_FUNCTION(jsFunction_stopCPUProfiler, (JSGlobalObject * globalObject, CallFrame*)) +{ + auto& vm = globalObject->vm(); + auto result = Bun::stopCPUProfilerAndGetJSON(vm); + return JSValue::encode(jsString(vm, result)); +} + +JSC_DECLARE_HOST_FUNCTION(jsFunction_setCPUSamplingInterval); +JSC_DEFINE_HOST_FUNCTION(jsFunction_setCPUSamplingInterval, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (callFrame->argumentCount() < 1) { + throwVMError(globalObject, scope, createNotEnoughArgumentsError(globalObject)); + return {}; + } + + int interval; + Bun::V::validateInteger(scope, globalObject, callFrame->uncheckedArgument(0), "interval"_s, jsNumber(1), jsUndefined(), &interval); + RETURN_IF_EXCEPTION(scope, {}); + + Bun::setSamplingInterval(interval); + return JSValue::encode(jsUndefined()); +} + +JSC_DECLARE_HOST_FUNCTION(jsFunction_isCPUProfilerRunning); +JSC_DEFINE_HOST_FUNCTION(jsFunction_isCPUProfilerRunning, (JSGlobalObject*, CallFrame*)) +{ + return JSValue::encode(jsBoolean(Bun::isCPUProfilerRunning())); +} diff --git a/src/bun.js/bindings/JSInspectorProfiler.h b/src/bun.js/bindings/JSInspectorProfiler.h new file mode 100644 index 0000000000..280cb7d0b6 --- /dev/null +++ b/src/bun.js/bindings/JSInspectorProfiler.h @@ -0,0 +1,9 @@ +#pragma once + +#include "root.h" +#include + +JSC_DECLARE_HOST_FUNCTION(jsFunction_startCPUProfiler); +JSC_DECLARE_HOST_FUNCTION(jsFunction_stopCPUProfiler); +JSC_DECLARE_HOST_FUNCTION(jsFunction_setCPUSamplingInterval); +JSC_DECLARE_HOST_FUNCTION(jsFunction_isCPUProfilerRunning); diff --git a/src/bun.js/modules/BunJSCModule.h b/src/bun.js/modules/BunJSCModule.h index a9d4e8b194..0b30cea5ac 100644 --- a/src/bun.js/modules/BunJSCModule.h +++ b/src/bun.js/modules/BunJSCModule.h @@ -655,6 +655,10 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca if (sampleValue.isNumber()) { unsigned sampleInterval = sampleValue.toUInt32(globalObject); samplingProfiler.setTimingInterval(Seconds::fromMicroseconds(sampleInterval)); + } else { + // Reset to default interval (1000 microseconds) to ensure each profile() + // call is independent of previous calls + samplingProfiler.setTimingInterval(Seconds::fromMicroseconds(1000)); } const auto report = [](JSC::VM& vm, @@ -670,7 +674,14 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca JSValue stackTraces = JSONParse(globalObject, samplingProfiler.stackTracesAsJSON()->toJSONString()); - samplingProfiler.shutdown(); + // Use pause() instead of shutdown() to allow the profiler to be restarted + // shutdown() sets m_isShutDown=true which is never reset, making the profiler unusable + { + auto& lock = samplingProfiler.getLock(); + WTF::Locker locker { lock }; + samplingProfiler.pause(); + samplingProfiler.clearData(); + } RETURN_IF_EXCEPTION(throwScope, {}); JSObject* result = constructEmptyObject(globalObject, globalObject->objectPrototype(), 3); @@ -682,8 +693,9 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca }; const auto reportFailure = [](JSC::VM& vm) -> JSC::JSValue { if (auto* samplingProfiler = vm.samplingProfiler()) { + auto& lock = samplingProfiler->getLock(); + WTF::Locker locker { lock }; samplingProfiler->pause(); - samplingProfiler->shutdown(); samplingProfiler->clearData(); } @@ -706,8 +718,11 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca JSNativeStdFunction* resolve = JSNativeStdFunction::create( vm, globalObject, 0, "resolve"_s, - [report](JSGlobalObject* globalObject, CallFrame* callFrame) { - return JSValue::encode(JSPromise::resolvedPromise(globalObject, report(globalObject->vm(), globalObject))); + [report](JSGlobalObject* globalObject, CallFrame* callFrame) -> JSC::EncodedJSValue { + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + JSValue result = report(globalObject->vm(), globalObject); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, JSValue::encode(JSPromise::resolvedPromise(globalObject, result))); }); JSNativeStdFunction* reject = JSNativeStdFunction::create( vm, globalObject, 0, "reject"_s, @@ -723,7 +738,8 @@ JSC_DEFINE_HOST_FUNCTION(functionRunProfiler, (JSGlobalObject * globalObject, Ca return JSValue::encode(afterOngoingPromiseCapability); } - return JSValue::encode(report(vm, globalObject)); + JSValue result = report(vm, globalObject); + RELEASE_AND_RETURN(throwScope, JSValue::encode(result)); } JSC_DECLARE_HOST_FUNCTION(functionGenerateHeapSnapshotForDebugging); diff --git a/src/js/node/inspector.promises.ts b/src/js/node/inspector.promises.ts new file mode 100644 index 0000000000..09c6a55ac8 --- /dev/null +++ b/src/js/node/inspector.promises.ts @@ -0,0 +1,28 @@ +// Hardcoded module "node:inspector/promises" +const inspector = require("node:inspector"); + +const { Session: BaseSession, console, open, close, url, waitForDebugger } = inspector; + +// Promise-based Session that wraps the callback-based Session +class Session extends BaseSession { + post(method: string, params?: object): Promise { + return new Promise((resolve, reject) => { + super.post(method, params, (err: Error | null, result: any) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } +} + +export default { + console, + open, + close, + url, + waitForDebugger, + Session, +}; diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index 1359de7f29..9195b48cd6 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -1,8 +1,14 @@ // Hardcoded module "node:inspector" and "node:inspector/promises" -// This is a stub! None of this is actually implemented yet. +// Profiler APIs are implemented; other inspector APIs are stubs. const { hideFromStack, throwNotImplemented } = require("internal/shared"); const EventEmitter = require("node:events"); +// Native profiler functions exposed via $newCppFunction +const startCPUProfiler = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_startCPUProfiler", 0); +const stopCPUProfiler = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_stopCPUProfiler", 0); +const setCPUSamplingInterval = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_setCPUSamplingInterval", 1); +const isCPUProfilerRunning = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_isCPUProfilerRunning", 0); + function open() { throwNotImplemented("node:inspector", 2445); } @@ -22,9 +28,110 @@ function waitForDebugger() { } class Session extends EventEmitter { - constructor() { - super(); - throwNotImplemented("node:inspector", 2445); + #connected = false; + #profilerEnabled = false; + + connect() { + if (this.#connected) { + throw new Error("Session is already connected"); + } + this.#connected = true; + } + + connectToMainThread() { + this.connect(); + } + + disconnect() { + if (!this.#connected) return; + if (isCPUProfilerRunning()) stopCPUProfiler(); + this.#profilerEnabled = false; + this.#connected = false; + } + + post( + method: string, + params?: object | ((err: Error | null, result?: any) => void), + callback?: (err: Error | null, result?: any) => void, + ) { + // Handle overloaded signature: post(method, callback) + if (typeof params === "function") { + callback = params; + params = undefined; + } + + if (!this.#connected) { + const error = new Error("Session is not connected"); + if (callback) { + queueMicrotask(() => callback(error)); + return; + } + throw error; + } + + const result = this.#handleMethod(method, params as object | undefined); + + if (callback) { + // Callback API - async + queueMicrotask(() => { + if (result instanceof Error) { + callback(result, undefined); + } else { + callback(null, result); + } + }); + } else { + // Sync throw for errors when no callback + if (result instanceof Error) { + throw result; + } + return result; + } + } + + #handleMethod(method: string, params?: object): any { + switch (method) { + case "Profiler.enable": + this.#profilerEnabled = true; + return {}; + + case "Profiler.disable": + if (isCPUProfilerRunning()) { + stopCPUProfiler(); + } + this.#profilerEnabled = false; + return {}; + + case "Profiler.start": + if (!this.#profilerEnabled) return new Error("Profiler is not enabled. Call Profiler.enable first."); + if (!isCPUProfilerRunning()) startCPUProfiler(); + return {}; + + case "Profiler.stop": + if (!isCPUProfilerRunning()) return new Error("Profiler is not started. Call Profiler.start first."); + try { + return { profile: JSON.parse(stopCPUProfiler()) }; + } catch (e) { + return new Error(`Failed to parse profile JSON: ${e}`); + } + + case "Profiler.setSamplingInterval": { + if (isCPUProfilerRunning()) return new Error("Cannot change sampling interval while profiler is running"); + const interval = (params as any)?.interval; + if (typeof interval !== "number" || interval <= 0) return new Error("interval must be a positive number"); + setCPUSamplingInterval(interval); + return {}; + } + + case "Profiler.getBestEffortCoverage": + case "Profiler.startPreciseCoverage": + case "Profiler.stopPreciseCoverage": + case "Profiler.takePreciseCoverage": + return new Error("Coverage APIs are not supported"); + + default: + return new Error(`Inspector method "${method}" is not supported`); + } } } diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index f43aca7404..5c438b9b65 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -183,4 +183,34 @@ describe("bun:jsc", () => { const input = await promise; expect({ ...input }).toStrictEqual({ "0": 2 }); }); + + it.todoIf(isBuildKite && isWindows)("profile can be called multiple times", () => { + // Fibonacci generates deep stacks and is CPU-intensive + function fib(n: number): number { + if (n <= 1) return n; + return fib(n - 1) + fib(n - 2); + } + + // First profile call + const result1 = profile(() => fib(30)); + expect(result1).toBeDefined(); + expect(result1.functions).toBeDefined(); + expect(result1.stackTraces).toBeDefined(); + expect(result1.stackTraces.traces.length).toBeGreaterThan(0); + + // Second profile call - should work after first one completed + // This verifies that shutdown() -> pause() fix works + const result2 = profile(() => fib(30)); + expect(result2).toBeDefined(); + expect(result2.functions).toBeDefined(); + expect(result2.stackTraces).toBeDefined(); + expect(result2.stackTraces.traces.length).toBeGreaterThan(0); + + // Third profile call - verify profiler can be reused multiple times + const result3 = profile(() => fib(30)); + expect(result3).toBeDefined(); + expect(result3.functions).toBeDefined(); + expect(result3.stackTraces).toBeDefined(); + expect(result3.stackTraces.traces.length).toBeGreaterThan(0); + }); }); diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts new file mode 100644 index 0000000000..64a0441ec7 --- /dev/null +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -0,0 +1,402 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import inspector from "node:inspector"; +import inspectorPromises from "node:inspector/promises"; + +describe("node:inspector", () => { + describe("Session", () => { + let session: inspector.Session; + + beforeEach(() => { + session = new inspector.Session(); + }); + + afterEach(() => { + try { + session.disconnect(); + } catch { + // Ignore if already disconnected + } + }); + + test("Session is a constructor", () => { + expect(inspector.Session).toBeInstanceOf(Function); + expect(session).toBeInstanceOf(inspector.Session); + }); + + test("Session extends EventEmitter", () => { + expect(typeof session.on).toBe("function"); + expect(typeof session.emit).toBe("function"); + expect(typeof session.removeListener).toBe("function"); + }); + + test("connect() establishes connection", () => { + expect(() => session.connect()).not.toThrow(); + }); + + test("connect() throws if already connected", () => { + session.connect(); + expect(() => session.connect()).toThrow("already connected"); + }); + + test("connectToMainThread() works like connect()", () => { + expect(() => session.connectToMainThread()).not.toThrow(); + }); + + test("disconnect() closes connection cleanly", () => { + session.connect(); + expect(() => session.disconnect()).not.toThrow(); + }); + + test("disconnect() is a no-op if not connected", () => { + expect(() => session.disconnect()).not.toThrow(); + }); + + test("post() throws if not connected", () => { + 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"); + }); + }); + + describe("Profiler", () => { + let session: inspector.Session; + + beforeEach(() => { + session = new inspector.Session(); + session.connect(); + }); + + afterEach(() => { + try { + session.disconnect(); + } catch { + // Ignore + } + }); + + test("Profiler.enable succeeds", () => { + const result = session.post("Profiler.enable"); + expect(result).toEqual({}); + }); + + test("Profiler.disable succeeds", () => { + session.post("Profiler.enable"); + const result = session.post("Profiler.disable"); + expect(result).toEqual({}); + }); + + test("Profiler.start without enable throws", () => { + expect(() => session.post("Profiler.start")).toThrow("not enabled"); + }); + + test("Profiler.start after enable succeeds", () => { + session.post("Profiler.enable"); + const result = session.post("Profiler.start"); + expect(result).toEqual({}); + }); + + test("Profiler.stop without start throws", () => { + session.post("Profiler.enable"); + expect(() => session.post("Profiler.stop")).toThrow("not started"); + }); + + test("Profiler.stop returns valid profile", () => { + session.post("Profiler.enable"); + session.post("Profiler.start"); + + // Do some work to generate profile data + let sum = 0; + for (let i = 0; i < 10000; i++) { + sum += Math.sqrt(i); + } + + const result = session.post("Profiler.stop"); + + expect(result).toHaveProperty("profile"); + const profile = result.profile; + + // Validate profile structure + expect(profile).toHaveProperty("nodes"); + expect(profile).toHaveProperty("startTime"); + expect(profile).toHaveProperty("endTime"); + expect(profile).toHaveProperty("samples"); + expect(profile).toHaveProperty("timeDeltas"); + + expect(profile.nodes).toBeArray(); + expect(profile.nodes.length).toBeGreaterThanOrEqual(1); + + // First node should be (root) + const rootNode = profile.nodes[0]; + expect(rootNode).toHaveProperty("id", 1); + expect(rootNode).toHaveProperty("callFrame"); + expect(rootNode.callFrame).toHaveProperty("functionName", "(root)"); + expect(rootNode.callFrame).toHaveProperty("scriptId", "0"); + expect(rootNode.callFrame).toHaveProperty("url", ""); + expect(rootNode.callFrame).toHaveProperty("lineNumber", -1); + expect(rootNode.callFrame).toHaveProperty("columnNumber", -1); + }); + + test("complete enable->start->stop workflow", () => { + // Enable profiler + const enableResult = session.post("Profiler.enable"); + expect(enableResult).toEqual({}); + + // Start profiling + const startResult = session.post("Profiler.start"); + expect(startResult).toEqual({}); + + // Do some work + function fibonacci(n: number): number { + if (n <= 1) return n; + return fibonacci(n - 1) + fibonacci(n - 2); + } + fibonacci(20); + + // Stop profiling + const stopResult = session.post("Profiler.stop"); + expect(stopResult).toHaveProperty("profile"); + + // Disable profiler + const disableResult = session.post("Profiler.disable"); + expect(disableResult).toEqual({}); + }); + + test("samples and timeDeltas have same length", () => { + session.post("Profiler.enable"); + session.post("Profiler.start"); + + // Do some work + let sum = 0; + for (let i = 0; i < 5000; i++) { + sum += Math.sqrt(i); + } + + const result = session.post("Profiler.stop"); + const profile = result.profile; + + expect(profile.samples.length).toBe(profile.timeDeltas.length); + }); + + test("samples reference valid node IDs", () => { + session.post("Profiler.enable"); + session.post("Profiler.start"); + + // Do some work + let sum = 0; + for (let i = 0; i < 5000; i++) { + sum += Math.sqrt(i); + } + + const result = session.post("Profiler.stop"); + const profile = result.profile; + + const nodeIds = new Set(profile.nodes.map((n: any) => n.id)); + for (const sample of profile.samples) { + expect(nodeIds.has(sample)).toBe(true); + } + }); + + test("Profiler.setSamplingInterval works", () => { + session.post("Profiler.enable"); + const result = session.post("Profiler.setSamplingInterval", { interval: 500 }); + expect(result).toEqual({}); + }); + + test("Profiler.setSamplingInterval throws if profiler is running", () => { + session.post("Profiler.enable"); + session.post("Profiler.start"); + expect(() => session.post("Profiler.setSamplingInterval", { interval: 500 })).toThrow( + "Cannot change sampling interval while profiler is running", + ); + session.post("Profiler.stop"); + }); + + test("Profiler.setSamplingInterval requires positive interval", () => { + session.post("Profiler.enable"); + expect(() => session.post("Profiler.setSamplingInterval", { interval: 0 })).toThrow(); + expect(() => session.post("Profiler.setSamplingInterval", { interval: -1 })).toThrow(); + }); + + test("double Profiler.start is a no-op", () => { + session.post("Profiler.enable"); + session.post("Profiler.start"); + const result = session.post("Profiler.start"); + expect(result).toEqual({}); + session.post("Profiler.stop"); + }); + + test("profiler can be restarted after stop", () => { + // First run + session.post("Profiler.enable"); + session.post("Profiler.start"); + let sum = 0; + for (let i = 0; i < 1000; i++) sum += i; + const result1 = session.post("Profiler.stop"); + expect(result1).toHaveProperty("profile"); + + // Second run + session.post("Profiler.start"); + for (let i = 0; i < 1000; i++) sum += i; + const result2 = session.post("Profiler.stop"); + expect(result2).toHaveProperty("profile"); + + // Both profiles should be valid + expect(result1.profile.nodes.length).toBeGreaterThanOrEqual(1); + expect(result2.profile.nodes.length).toBeGreaterThanOrEqual(1); + }); + + test("disconnect() stops running profiler", () => { + session.post("Profiler.enable"); + session.post("Profiler.start"); + session.disconnect(); + + // Create new session and verify profiler was stopped + const session2 = new inspector.Session(); + session2.connect(); + session2.post("Profiler.enable"); + + // This should work without error (profiler is not running) + const result = session2.post("Profiler.setSamplingInterval", { interval: 500 }); + expect(result).toEqual({}); + session2.disconnect(); + }); + }); + + describe("callback API", () => { + test("post() with callback receives result", async () => { + const session = new inspector.Session(); + session.connect(); + + const { promise, resolve } = Promise.withResolvers(); + session.post("Profiler.enable", (err, result) => { + resolve({ err, result }); + }); + + const { err, result } = await promise; + expect(err).toBeNull(); + expect(result).toEqual({}); + session.disconnect(); + }); + + test("post() with callback receives error", async () => { + const session = new inspector.Session(); + session.connect(); + + const { promise, resolve } = Promise.withResolvers(); + session.post("Profiler.start", (err, result) => { + resolve({ err, result }); + }); + + const { err, result } = await promise; + expect(err).toBeInstanceOf(Error); + expect(err.message).toContain("not enabled"); + session.disconnect(); + }); + }); + + describe("unsupported methods", () => { + test("unsupported method throws", () => { + const session = new inspector.Session(); + session.connect(); + expect(() => session.post("Runtime.evaluate")).toThrow("not supported"); + session.disconnect(); + }); + + test("coverage APIs throw not supported", () => { + const session = new inspector.Session(); + session.connect(); + session.post("Profiler.enable"); + expect(() => session.post("Profiler.getBestEffortCoverage")).toThrow("not supported"); + expect(() => session.post("Profiler.startPreciseCoverage")).toThrow("not supported"); + expect(() => session.post("Profiler.stopPreciseCoverage")).toThrow("not supported"); + expect(() => session.post("Profiler.takePreciseCoverage")).toThrow("not supported"); + session.disconnect(); + }); + }); + + describe("exports", () => { + test("url() returns undefined", () => { + expect(inspector.url()).toBeUndefined(); + }); + + test("console is exported", () => { + expect(inspector.console).toBeObject(); + expect(inspector.console.log).toBe(globalThis.console.log); + }); + + test("open() throws not implemented", () => { + expect(() => inspector.open()).toThrow(); + }); + + test("close() throws not implemented", () => { + expect(() => inspector.close()).toThrow(); + }); + + test("waitForDebugger() throws not implemented", () => { + expect(() => inspector.waitForDebugger()).toThrow(); + }); + }); +}); + +describe("node:inspector/promises", () => { + test("Session is exported", () => { + expect(inspectorPromises.Session).toBeInstanceOf(Function); + }); + + test("post() returns a Promise", async () => { + const session = new inspectorPromises.Session(); + session.connect(); + + const result = session.post("Profiler.enable"); + expect(result).toBeInstanceOf(Promise); + + await expect(result).resolves.toEqual({}); + session.disconnect(); + }); + + test("post() rejects on error", async () => { + const session = new inspectorPromises.Session(); + session.connect(); + + await expect(session.post("Profiler.start")).rejects.toThrow("not enabled"); + session.disconnect(); + }); + + test("complete profiling workflow with promises", async () => { + const session = new inspectorPromises.Session(); + session.connect(); + + await session.post("Profiler.enable"); + await session.post("Profiler.start"); + + // Do some work + function work(n: number): number { + if (n <= 1) return n; + return work(n - 1) + work(n - 2); + } + work(15); + + const result = await session.post("Profiler.stop"); + expect(result).toHaveProperty("profile"); + expect(result.profile.nodes).toBeArray(); + + await session.post("Profiler.disable"); + session.disconnect(); + }); + + test("other exports are the same as node:inspector", () => { + expect(inspectorPromises.url).toBe(inspector.url); + expect(inspectorPromises.console).toBe(inspector.console); + expect(inspectorPromises.open).toBe(inspector.open); + expect(inspectorPromises.close).toBe(inspector.close); + expect(inspectorPromises.waitForDebugger).toBe(inspector.waitForDebugger); + }); +});