diff --git a/CHANGELOG.md b/CHANGELOG.md index 64147aa3da97..4b5fe2f2bc99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -194,3 +194,4 @@ - Sync upstream nightly through `d4928764f23213ecf3cd61fa0b5b4a44369a5096`, configuring fetch TLS once per connection so pooled sockets can be reused during renegotiation without repeating session setup. - Sync upstream nightly through `9bd19c98eacc01530a4e7609bc427abffa87d77e`, preserving PostgreSQL query ordering through errors and limiting MySQL row-decoding failures to the affected query. +- Support real byte-based `node:inspector` HeapProfiler allocation sampling with allocation-site trees, live profiles, and major/minor GC inclusion flags (requires the OpenClaw WebKit allocation sampler). diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index d83684a0dca7..b2e009d10571 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -322,3 +322,25 @@ Error: here! Error: here! at /path/to/index.ts:18 ``` + +## In-process allocation sampling + +`node:inspector` and `node:inspector/promises` support `HeapProfiler.startSampling`, +`HeapProfiler.getSamplingProfile`, and `HeapProfiler.stopSampling`. A local session +samples its own JavaScriptCore VM, including in a worker. The profile contains a +`head` allocation-site tree with estimated allocated bytes in `selfSize`, and +`samples` with `nodeId`, `size`, and unique `ordinal` fields. + +`samplingInterval` is the mean byte interval and defaults to 32,768. The profiler +samples JavaScriptCore cells, newly reported string backing storage, and GC-managed +auxiliary storage; other native allocations and external buffer storage are outside +this measurement. Engine object layouts +and sampling randomness affect the estimates. + +Getting or stopping a profile synchronously runs full garbage collection, matching Node. +By default, collected allocations disappear from the profile. Set +`includeObjectsCollectedByMajorGC` and `includeObjectsCollectedByMinorGC` to `true` +to measure cumulative allocations across both collection types. Sampling does not +retain the sampled objects. A repeated start keeps the current profile. Stop, +`HeapProfiler.disable`, or disconnect releases sampling started by that session. +Sampling through a worker's `connectToMainThread()` is not supported. diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 13378fa7cb4d..a368e2eb0736 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -310,7 +310,7 @@ Writes also reach the shared environment when value coercion creates the first w ### [`node:inspector`](https://nodejs.org/api/inspector.html) -🟡 Partially implemented. `Session` supports the `Profiler` domain (including precise coverage), `Runtime.enable` and `NodeTracing`, from both `node:inspector` and `node:inspector/promises`. `HeapProfiler.enable`, `HeapProfiler.disable`, and `HeapProfiler.collectGarbage` work in main-thread and worker sessions; collection runs after the current JavaScript job and completes before its callback. Heap allocation sampling and garbage collection through a worker's `connectToMainThread()` session are not implemented. After `open()`, `Session` also forwards `Debugger` configuration commands such as `Debugger.enable` and `Debugger.setBreakpointByUrl` to the inspector server. Their results, such as `breakpointId`, are not returned. Other `Session` commands such as `Runtime.evaluate` are not implemented. `open()`, `url()`, `close()` and `waitForDebugger()` are implemented. `open()` serves the `Debugger` and `Runtime` domains and throws in workers. Missing `Network`. +🟡 Partially implemented. `Session` supports the `Profiler` domain (including precise coverage), `Runtime.enable` and `NodeTracing`, from both `node:inspector` and `node:inspector/promises`. `HeapProfiler.enable`, `HeapProfiler.disable`, and `HeapProfiler.collectGarbage` work in main-thread and worker sessions; collection runs after the current JavaScript job and completes before its callback. `HeapProfiler.startSampling`, `getSamplingProfile`, and `stopSampling` provide byte-based allocation-site profiles, including separate major/minor GC inclusion flags, through local main-thread and worker sessions. Sampling and garbage collection through a worker's `connectToMainThread()` session are not implemented. After `open()`, `Session` also forwards `Debugger` configuration commands such as `Debugger.enable` and `Debugger.setBreakpointByUrl` to the inspector server. Their results, such as `breakpointId`, are not returned. Other `Session` commands such as `Runtime.evaluate` are not implemented. `open()`, `url()`, `close()` and `waitForDebugger()` are implemented. `open()` serves the `Debugger` and `Runtime` domains and throws in workers. Missing `Network`. ### [`node:repl`](https://nodejs.org/api/repl.html) diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 1a292a5ffe6e..eacc4c898b6f 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -183,6 +183,7 @@ declare function $isRegExpObject(obj: unknown): obj is RegExp; declare function $isMap(obj: unknown): obj is Map; declare function $isSet(obj: unknown): obj is Set; declare function $isUndefinedOrNull(obj: unknown): obj is null | undefined; +declare function $isFinite(value: number): boolean; declare function $tailCallForwardArguments(fn: CallableFunction, thisValue: ThisType): any; /** * **NOTE** - use `throw new TypeError()` instead. it compiles to the same builtin diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index 392df660615b..6752b84cac17 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -25,6 +25,13 @@ const startPreciseCoverage = $newCppFunction("JSInspectorProfiler.cpp", "jsFunct const stopPreciseCoverage = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_stopPreciseCoverage", 0); const collectPreciseCoverage = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_collectPreciseCoverage", 0); const collectInspectorGarbage = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_collectInspectorGarbage", 0); +const startAllocationSampling = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_startAllocationSampling", 3); +const stopAllocationSampling = $newCppFunction("JSInspectorProfiler.cpp", "jsFunction_stopAllocationSampling", 0); +const getAllocationSamplingProfile = $newCppFunction( + "JSInspectorProfiler.cpp", + "jsFunction_getAllocationSamplingProfile", + 0, +); // Native bindings for inspector.open(): they start Bun's debugger thread with a // WebSocket server that speaks the V8 Chrome DevTools Protocol (see @@ -428,6 +435,7 @@ class Session extends EventEmitter { #preciseCoverageCallCount = false; #preciseCoverageDetailed = false; #forwardedDebugger = false; + #samplingAllocations = false; #pendingCollections: Set<{ callback: (err: Error | null, result?: any) => void }> = new SafeSet(); // Baseline for delta semantics: takePreciseCoverage must reset counters, but // JSC has no counter-reset API, so subtract the previous take instead. @@ -470,6 +478,10 @@ class Session extends EventEmitter { disconnect() { if (!this.#connected) return; + if (this.#samplingAllocations) { + stopAllocationSampling(); + this.#samplingAllocations = false; + } if (isCPUProfilerRunning()) stopCPUProfiler(); if (this.#preciseCoverageEnabled) { stopPreciseCoverage(); @@ -514,10 +526,30 @@ class Session extends EventEmitter { } if (method === "HeapProfiler.enable" || method === "HeapProfiler.disable") { + if (method === "HeapProfiler.disable" && this.#samplingAllocations) { + stopAllocationSampling(); + this.#samplingAllocations = false; + } if (callback) this.#heapCallback(callback, {}); return; } + if ( + !this.#connectedToMainThread && + (method === "HeapProfiler.startSampling" || + method === "HeapProfiler.stopSampling" || + method === "HeapProfiler.getSamplingProfile") + ) { + const result = this.#allocationSampling(method, params as Record | undefined); + if (callback) + this.#heapCallback( + callback, + result instanceof Error ? undefined : result, + result instanceof Error ? result : null, + ); + return; + } + if (method === "HeapProfiler.collectGarbage" && !this.#connectedToMainThread) { const collection = collectInspectorGarbage(); if (callback) { @@ -558,14 +590,44 @@ class Session extends EventEmitter { } } - #heapCallback(callback: (err: Error | null, result?: any) => void, result: any) { + #heapCallback(callback: (err: Error | null, result?: any) => void, result: any, error: Error | null = null) { try { - callback(null, result); + callback(error, result); } catch (error) { process.emitWarning(error as Error); } } + #allocationSampling(method: string, params?: Record): object | Error { + if (method === "HeapProfiler.startSampling") { + const interval = params?.samplingInterval === undefined ? 32768 : params.samplingInterval; + const major = + params?.includeObjectsCollectedByMajorGC === undefined ? false : params.includeObjectsCollectedByMajorGC; + const minor = + params?.includeObjectsCollectedByMinorGC === undefined ? false : params.includeObjectsCollectedByMinorGC; + if ( + typeof interval !== "number" || + !$isFinite(interval) || + typeof major !== "boolean" || + typeof minor !== "boolean" + ) { + return $ERR_INSPECTOR_COMMAND("-32602: Invalid parameters"); + } + // V8 truncates the byte interval; sub-byte intervals cannot sample safely. + if (interval < 1) return $ERR_INSPECTOR_COMMAND("-32000: Invalid sampling interval"); + startAllocationSampling(interval, major, minor); + this.#samplingAllocations = true; + return {}; + } + const raw = getAllocationSamplingProfile(); + if (raw === null) return $ERR_INSPECTOR_COMMAND("-32000: V8 sampling heap profiler was not started."); + if (method === "HeapProfiler.stopSampling") { + stopAllocationSampling(); + this.#samplingAllocations = false; + } + return { profile: JSON.parse(raw) }; + } + #handleMethod(method: string, params?: object): any { switch (method) { case "Runtime.enable": diff --git a/src/jsc/bindings/JSInspectorProfiler.cpp b/src/jsc/bindings/JSInspectorProfiler.cpp index 78ca21c90e40..1998ab59aab0 100644 --- a/src/jsc/bindings/JSInspectorProfiler.cpp +++ b/src/jsc/bindings/JSInspectorProfiler.cpp @@ -12,16 +12,49 @@ #include #include #include +#include #include #include #include #include #include +#include using namespace JSC; extern "C" size_t Bun__gc(void* vm, bool sync); +JSC_DECLARE_HOST_FUNCTION(jsFunction_startAllocationSampling); +JSC_DEFINE_HOST_FUNCTION(jsFunction_startAllocationSampling, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + globalObject->vm().ensureHeapProfiler().startAllocationSampling( + std::floor(callFrame->argument(0).asNumber()), callFrame->argument(1).asBoolean(), callFrame->argument(2).asBoolean()); + return JSValue::encode(jsUndefined()); +} + +JSC_DECLARE_HOST_FUNCTION(jsFunction_stopAllocationSampling); +JSC_DEFINE_HOST_FUNCTION(jsFunction_stopAllocationSampling, (JSGlobalObject * globalObject, CallFrame*)) +{ + if (auto* profiler = globalObject->vm().heapProfiler()) + profiler->stopAllocationSampling(); + return JSValue::encode(jsUndefined()); +} + +JSC_DECLARE_HOST_FUNCTION(jsFunction_getAllocationSamplingProfile); +JSC_DEFINE_HOST_FUNCTION(jsFunction_getAllocationSamplingProfile, (JSGlobalObject * globalObject, CallFrame*)) +{ + auto& vm = globalObject->vm(); + auto* profiler = vm.heapProfiler(); + if (!profiler || !profiler->isSamplingAllocations()) + return JSValue::encode(jsNull()); + // Node's inspector sets V8's kSamplingForceGC before retrieving a profile. + Bun__gc(Bun::vm(globalObject), true); + profiler = vm.heapProfiler(); + if (!profiler || !profiler->isSamplingAllocations()) + return JSValue::encode(jsNull()); + return JSValue::encode(jsString(vm, profiler->allocationSamplingProfile())); +} + JSC_DECLARE_HOST_FUNCTION(jsFunction_collectInspectorGarbage); JSC_DEFINE_HOST_FUNCTION(jsFunction_collectInspectorGarbage, (JSGlobalObject * globalObject, CallFrame*)) { diff --git a/src/jsc/bindings/JSInspectorProfiler.h b/src/jsc/bindings/JSInspectorProfiler.h index 48f6640aadcb..421077f139ec 100644 --- a/src/jsc/bindings/JSInspectorProfiler.h +++ b/src/jsc/bindings/JSInspectorProfiler.h @@ -4,6 +4,9 @@ #include JSC_DECLARE_HOST_FUNCTION(jsFunction_collectInspectorGarbage); +JSC_DECLARE_HOST_FUNCTION(jsFunction_startAllocationSampling); +JSC_DECLARE_HOST_FUNCTION(jsFunction_stopAllocationSampling); +JSC_DECLARE_HOST_FUNCTION(jsFunction_getAllocationSamplingProfile); JSC_DECLARE_HOST_FUNCTION(jsFunction_startCPUProfiler); JSC_DECLARE_HOST_FUNCTION(jsFunction_stopCPUProfiler); JSC_DECLARE_HOST_FUNCTION(jsFunction_setCPUSamplingInterval); diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index 268e362f2278..356ba88ef7cf 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -218,6 +218,180 @@ describe("node:inspector", () => { }); describe("HeapProfiler", () => { + test("worker allocation sampling is independent of the main VM", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), import.meta.dir + "/inspector-sampling-worker.fixture.cjs"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "worker allocation sampling passed\n", + stderr: "", + exitCode: 0, + }); + }); + + test("byte allocation sampling retains collected samples, tracks live samples, and matches Session lifecycle", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--input-type=module", + "-e", + ` +import assert from 'node:assert/strict'; +import { Session } from 'node:inspector/promises'; +import { Session as CallbackSession } from 'node:inspector'; +const session = new Session(); +session.connect(); +let retained; +function allocateObjects(count) { + retained = Array.from({ length: count }, (_, index) => ({ index, x: index + 1, y: index + 2 })); +} +function allocateArrays(count) { + retained = Array.from({ length: count }, (_, index) => [index, index + 1, index + 2]); +} +function total(node, name) { + return (node.callFrame.functionName === name ? node.selfSize : 0) + node.children.reduce((sum, child) => sum + total(child, name), 0); +} +function validate(profile) { + const nodes = new Map(); + let bytes = 0; + const walk = node => { + assert(!nodes.has(node.id)); + nodes.set(node.id, node); + assert(node.selfSize >= 0); + assert.equal(typeof node.callFrame.functionName, 'string'); + assert.equal(typeof node.callFrame.scriptId, 'string'); + assert.equal(typeof node.callFrame.url, 'string'); + assert(Number.isInteger(node.callFrame.lineNumber)); + assert(Number.isInteger(node.callFrame.columnNumber)); + bytes += node.selfSize; + node.children.forEach(walk); + }; + walk(profile.head); + const ordinals = new Set(); + let sampleBytes = 0; + for (const sample of profile.samples) { + assert(nodes.has(sample.nodeId)); + assert(sample.size > 0); + assert(Number.isInteger(sample.ordinal) && sample.ordinal > 0); + assert(!ordinals.has(sample.ordinal)); + ordinals.add(sample.ordinal); + sampleBytes += sample.size; + } + if (profile.samples.length) { + assert(bytes > 0 && sampleBytes > 0); + assert(Math.abs(bytes - sampleBytes) < bytes / 4); + } + return bytes; +} +try { + await assert.rejects(session.post('HeapProfiler.getSamplingProfile'), { code: 'ERR_INSPECTOR_COMMAND' }); + for (let i = 0; i < 30; ++i) { allocateObjects(1000); allocateArrays(1000); } + retained = undefined; + await session.post('HeapProfiler.collectGarbage'); + await session.post('HeapProfiler.startSampling', { + samplingInterval: 1024, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + // A repeated start keeps existing samples and their ordinals. + allocateObjects(20000); + const first = (await session.post('HeapProfiler.getSamplingProfile')).profile; + assert(validate(first) > 100000); + await session.post('HeapProfiler.startSampling', { samplingInterval: 32768 }); + allocateArrays(20000); + retained = undefined; + await session.post('HeapProfiler.collectGarbage'); + const last = (await session.post('HeapProfiler.stopSampling')).profile; + assert(validate(last) >= validate(first)); + const byOrdinal = new Map(last.samples.map(sample => [sample.ordinal, sample])); + for (const sample of first.samples) assert.deepEqual(byOrdinal.get(sample.ordinal), sample); + assert(total(last.head, 'allocateObjects') + total(last.head, '') > 0); + assert(last.samples.length > first.samples.length); + await assert.rejects(session.post('HeapProfiler.stopSampling'), { code: 'ERR_INSPECTOR_COMMAND' }); + // Default flags discard dead allocations rather than strongly retaining them. + await session.post('HeapProfiler.startSampling', { samplingInterval: 512 }); + allocateObjects(30000); + const before = validate((await session.post('HeapProfiler.getSamplingProfile')).profile); + retained = undefined; + const liveAfter = validate((await session.post('HeapProfiler.getSamplingProfile')).profile); + assert(liveAfter < before / 3, \`live profile did not collect: \${before} -> \${liveAfter}\`); + const after = validate((await session.post('HeapProfiler.stopSampling')).profile); + assert(before > 100000); + assert(after < before / 3, \`dead samples remain: \${before} -> \${after}\`); + await session.post('HeapProfiler.startSampling'); + await session.post('HeapProfiler.disable'); + await assert.rejects(session.post('HeapProfiler.getSamplingProfile'), { code: 'ERR_INSPECTOR_COMMAND' }); + await session.post('HeapProfiler.startSampling'); + session.disconnect(); + session.connect(); + await assert.rejects(session.post('HeapProfiler.getSamplingProfile'), { code: 'ERR_INSPECTOR_COMMAND' }); +} finally { session.disconnect(); } +const callback = new CallbackSession(); +callback.connect(); +try { + for (const [target, name] of [[Math, 'floor'], [Number, 'isFinite']]) { + const original = target[name]; + let completed = false; + let callbackError; + target[name] = () => { throw new Error('mutable numeric builtin called'); }; + try { + callback.post('HeapProfiler.startSampling', { samplingInterval: 1024.75 }, error => { + callbackError = error; + completed = true; + }); + } finally { + target[name] = original; + } + assert.equal(completed, true); + assert.equal(callbackError, null); + callback.post('HeapProfiler.stopSampling', error => assert.equal(error, null)); + } + for (const [params, message] of [ + [{ samplingInterval: 0 }, '-32000: Invalid sampling interval'], + [{ samplingInterval: -1 }, '-32000: Invalid sampling interval'], + [{ samplingInterval: '32' }, '-32602: Invalid parameters'], + [{ samplingInterval: null }, '-32602: Invalid parameters'], + [{ samplingInterval: NaN }, '-32602: Invalid parameters'], + [{ includeObjectsCollectedByMajorGC: 1 }, '-32602: Invalid parameters'], + [{ includeObjectsCollectedByMinorGC: null }, '-32602: Invalid parameters'], + ]) { + let called = false; + assert.equal(callback.post('HeapProfiler.startSampling', params, (error, result) => { + called = true; + assert.equal(error.code, 'ERR_INSPECTOR_COMMAND'); + assert.equal(error.message, 'Inspector error ' + message); + assert.equal(result, undefined); + }), undefined); + assert(called, 'callback was not synchronous'); + } + assert.equal(callback.post('HeapProfiler.startSampling'), undefined); + let completed = false; + callback.post('HeapProfiler.stopSampling', (error, { profile }) => { + assert.equal(error, null); + validate(profile); + completed = true; + }); + assert(completed); +} finally { callback.disconnect(); } +console.log('allocation sampling contract passed'); +`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "allocation sampling contract passed\n", + stderr: "", + exitCode: 0, + }); + }); + test("enable and disable complete synchronously and return undefined", () => { const session = new inspector.Session(); session.connect(); diff --git a/test/js/node/inspector/inspector-sampling-worker.fixture.cjs b/test/js/node/inspector/inspector-sampling-worker.fixture.cjs new file mode 100644 index 000000000000..47ce7558c0a4 --- /dev/null +++ b/test/js/node/inspector/inspector-sampling-worker.fixture.cjs @@ -0,0 +1,63 @@ +const assert = require("node:assert/strict"); +const { Worker, isMainThread, parentPort } = require("node:worker_threads"); +const { Session } = require("node:inspector/promises"); +async function run() { + const session = new Session(); + session.connect(); + try { + if (isMainThread) { + await session.post("HeapProfiler.startSampling", { samplingInterval: 1024 }); + const worker = new Worker(__filename); + try { + await new Promise((resolve, reject) => { + let result; + worker.on("error", reject); + worker.on("message", async message => { + try { + if (message === "ready") { + await session.post("HeapProfiler.stopSampling"); + worker.postMessage("finish"); + } else result = message; + } catch (error) { + reject(error); + } + }); + worker.once("exit", code => { + try { + assert.equal(code, 0); + assert(result?.samples > 0 && result?.bytes > 100000); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + } finally { + await worker.terminate(); + } + console.log("worker allocation sampling passed"); + } else { + await session.post("HeapProfiler.startSampling", { + samplingInterval: 1024, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + parentPort.postMessage("ready"); + await new Promise(resolve => parentPort.once("message", resolve)); + let retained = Array.from({ length: 20000 }, (_, i) => ({ i, a: i + 1, b: i + 2 })); + assert.equal(retained.length, 20000); + retained = undefined; + await session.post("HeapProfiler.collectGarbage"); + const { profile } = await session.post("HeapProfiler.stopSampling"); + const sum = node => node.selfSize + node.children.reduce((n, child) => n + sum(child), 0); + parentPort.postMessage({ samples: profile.samples.length, bytes: sum(profile.head) }); + parentPort.close(); + } + } finally { + session.disconnect(); + } +} +run().catch(error => { + console.error(error); + process.exitCode = 1; +});