Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
22 changes: 22 additions & 0 deletions docs/runtime/debugger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/runtime/nodejs-compat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/js/builtins.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ declare function $isRegExpObject(obj: unknown): obj is RegExp;
declare function $isMap<K, V>(obj: unknown): obj is Map<K, V>;
declare function $isSet<V>(obj: unknown): obj is Set<V>;
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
Expand Down
66 changes: 64 additions & 2 deletions src/js/node/inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, unknown> | 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) {
Expand Down Expand Up @@ -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<string, unknown>): 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":
Expand Down
33 changes: 33 additions & 0 deletions src/jsc/bindings/JSInspectorProfiler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,49 @@
#include <JavaScriptCore/ControlFlowProfiler.h>
#include <JavaScriptCore/FunctionHasExecutedCache.h>
#include <JavaScriptCore/HeapIterationScope.h>
#include <JavaScriptCore/HeapProfiler.h>
#include <JavaScriptCore/MarkedSpaceInlines.h>
#include <JavaScriptCore/ScriptExecutable.h>
#include <JavaScriptCore/SourceProvider.h>
#include <JavaScriptCore/SubspaceInlines.h>
#include <wtf/JSONValues.h>
#include <cmath>

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*))
{
Expand Down
3 changes: 3 additions & 0 deletions src/jsc/bindings/JSInspectorProfiler.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
#include <JavaScriptCore/JSCJSValue.h>

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);
Expand Down
174 changes: 174 additions & 0 deletions test/js/node/inspector/inspector-profiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
return best;
}

describe("node:inspector", () => {

Check failure on line 155 in test/js/node/inspector/inspector-profiler.test.ts

View workflow job for this annotation

GitHub Actions / linux-x64

error: Cannot call describe() after the test run has completed

at /home/runner/_work/bun/bun/test/js/node/inspector/inspector-profiler.test.ts:155:1
describe("Session", () => {
let session: inspector.Session;

Expand Down Expand Up @@ -218,6 +218,180 @@
});

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();
Expand Down
Loading
Loading