diff --git a/src/bun.js.zig b/src/bun.js.zig index 96936ec775..fdf33b3445 100644 --- a/src/bun.js.zig +++ b/src/bun.js.zig @@ -281,8 +281,8 @@ pub const Run = struct { const cpu_prof_opts = this.ctx.runtime_options.cpu_prof; vm.cpu_profiler_config = CPUProfiler.CPUProfilerConfig{ - .name = cpu_prof_opts.name orelse bun.env_var.BUN_CPU_PROFILE_NAME.get() orelse "", - .dir = cpu_prof_opts.dir orelse bun.env_var.BUN_CPU_PROFILE_DIR.get() orelse "", + .name = if (cpu_prof_opts.name.len > 0) cpu_prof_opts.name else bun.env_var.BUN_CPU_PROFILE_NAME.get() orelse "", + .dir = if (cpu_prof_opts.dir.len > 0) cpu_prof_opts.dir else bun.env_var.BUN_CPU_PROFILE_DIR.get() orelse "", .md_format = cpu_prof_opts.md_format, .json_format = cpu_prof_opts.json_format, }; @@ -290,6 +290,18 @@ pub const Run = struct { bun.analytics.Features.cpu_profile += 1; } + // Set up heap profiler config if enabled (actual profiling happens on exit) + if (this.ctx.runtime_options.heap_prof.enabled) { + const heap_prof_opts = this.ctx.runtime_options.heap_prof; + + vm.heap_profiler_config = HeapProfiler.HeapProfilerConfig{ + .name = heap_prof_opts.name, + .dir = heap_prof_opts.dir, + .text_format = heap_prof_opts.text_format, + }; + bun.analytics.Features.heap_snapshot += 1; + } + this.addConditionalGlobals(); do_redis_preconnect: { // This must happen within the API lock, which is why it's not in the "doPreconnect" function @@ -551,6 +563,7 @@ const VirtualMachine = jsc.VirtualMachine; const string = []const u8; const CPUProfiler = @import("./bun.js/bindings/BunCPUProfiler.zig"); +const HeapProfiler = @import("./bun.js/bindings/BunHeapProfiler.zig"); const options = @import("./options.zig"); const std = @import("std"); const Command = @import("./cli.zig").Command; diff --git a/src/bun.js/VirtualMachine.zig b/src/bun.js/VirtualMachine.zig index e73dfa8ec5..86ee29b136 100644 --- a/src/bun.js/VirtualMachine.zig +++ b/src/bun.js/VirtualMachine.zig @@ -49,6 +49,7 @@ standalone_module_graph: ?*bun.StandaloneModuleGraph = null, smol: bool = false, dns_result_order: DNSResolver.Order = .verbatim, cpu_profiler_config: ?CPUProfilerConfig = null, +heap_profiler_config: ?HeapProfilerConfig = null, counters: Counters = .{}, hot_reload: bun.cli.Command.HotReload = .none, @@ -843,6 +844,15 @@ pub fn onExit(this: *VirtualMachine) void { }; } + // Write heap profile if profiling was enabled - do this after CPU profile but before shutdown + // Grab the config and null it out to make this idempotent + if (this.heap_profiler_config) |config| { + this.heap_profiler_config = null; + HeapProfiler.generateAndWriteProfile(this.jsc_vm, config) catch |err| { + Output.err(err, "Failed to write heap profile", .{}); + }; + } + this.exit_handler.dispatchOnExit(); this.is_shutting_down = true; @@ -3715,6 +3725,9 @@ const Allocator = std.mem.Allocator; const CPUProfiler = @import("./bindings/BunCPUProfiler.zig"); const CPUProfilerConfig = CPUProfiler.CPUProfilerConfig; +const HeapProfiler = @import("./bindings/BunHeapProfiler.zig"); +const HeapProfilerConfig = HeapProfiler.HeapProfilerConfig; + const bun = @import("bun"); const Async = bun.Async; const DotEnv = bun.DotEnv; diff --git a/src/bun.js/bindings/BunCPUProfiler.zig b/src/bun.js/bindings/BunCPUProfiler.zig index b17c53eb38..6b8dd69f11 100644 --- a/src/bun.js/bindings/BunCPUProfiler.zig +++ b/src/bun.js/bindings/BunCPUProfiler.zig @@ -61,7 +61,7 @@ fn writeProfileToFile(profile_string: bun.String, config: CPUProfilerConfig, is_ const errno = err.getErrno(); if (errno == .NOENT or errno == .PERM or errno == .ACCES) { if (config.dir.len > 0) { - bun.makePath(bun.FD.cwd().stdDir(), config.dir) catch {}; + bun.FD.cwd().makePath(u8, config.dir) catch {}; // Retry write const retry_result = bun.sys.File.writeFile(bun.FD.cwd(), output_path_os, profile_slice.slice()); if (retry_result.asErr()) |_| { diff --git a/src/bun.js/bindings/BunHeapProfiler.cpp b/src/bun.js/bindings/BunHeapProfiler.cpp new file mode 100644 index 0000000000..07be7232e2 --- /dev/null +++ b/src/bun.js/bindings/BunHeapProfiler.cpp @@ -0,0 +1,961 @@ +#include "root.h" +#include "BunHeapProfiler.h" +#include "headers-handwritten.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Bun { + +// Type aliases for hash containers that allow 0 as a valid key +// (heap node IDs can be 0 for the root node) +template +using NodeIdHashMap = WTF::HashMap, WTF::UnsignedWithZeroKeyHashTraits>; +using NodeIdHashSet = WTF::HashSet, WTF::UnsignedWithZeroKeyHashTraits>; + +BunString toStringRef(const WTF::String& wtfString); + +// Node data parsed from snapshot +struct NodeData { + uint64_t id; + size_t size; + int classNameIndex; + int flags; + int labelIndex { -1 }; + size_t retainedSize { 0 }; + bool isGCRoot { false }; + bool isInternal { false }; +}; + +// Edge data parsed from snapshot +struct EdgeData { + uint64_t fromId; + uint64_t toId; + int typeIndex; + int dataIndex; +}; + +// Type statistics for summary +struct TypeStats { + WTF::String name; + size_t totalSize { 0 }; + size_t totalRetainedSize { 0 }; + size_t count { 0 }; + size_t largestRetained { 0 }; + uint64_t largestInstanceId { 0 }; +}; + +// Escape string for safe output (replace newlines, tabs, etc.) +static WTF::String escapeString(const WTF::String& str) +{ + if (str.isEmpty()) + return str; + + WTF::StringBuilder sb; + for (unsigned i = 0; i < str.length(); i++) { + UChar c = str[i]; + if (c == '\n') + sb.append("\\n"_s); + else if (c == '\r') + sb.append("\\r"_s); + else if (c == '\t') + sb.append("\\t"_s); + else if (c == '\\') + sb.append("\\\\"_s); + else if (c == '"') + sb.append("\\\""_s); + else if (c == '|') + sb.append("\\|"_s); + else if (c == '`') + sb.append("\\`"_s); // escape backticks to avoid breaking markdown code spans + else if (c < 32 || c == 127) + continue; // skip control characters + else + sb.append(c); + } + return sb.toString(); +} + +// Format bytes nicely for human-readable sections +static WTF::String formatBytes(size_t bytes) +{ + WTF::StringBuilder sb; + if (bytes < 1024) { + sb.append(bytes); + sb.append(" B"_s); + } else if (bytes < 1024 * 1024) { + sb.append(bytes / 1024); + sb.append("."_s); + sb.append((bytes % 1024) * 10 / 1024); + sb.append(" KB"_s); + } else if (bytes < 1024ULL * 1024 * 1024) { + sb.append(bytes / (1024 * 1024)); + sb.append("."_s); + sb.append((bytes % (1024 * 1024)) * 10 / (1024 * 1024)); + sb.append(" MB"_s); + } else { + sb.append(bytes / (1024ULL * 1024 * 1024)); + sb.append("."_s); + sb.append((bytes % (1024ULL * 1024 * 1024)) * 10 / (1024ULL * 1024 * 1024)); + sb.append(" GB"_s); + } + return sb.toString(); +} + +WTF::String generateHeapProfile(JSC::VM& vm) +{ + vm.ensureHeapProfiler(); + auto& heapProfiler = *vm.heapProfiler(); + heapProfiler.clearSnapshots(); + + // Build the heap snapshot using JSC's GCDebugging format for more detail + JSC::HeapSnapshotBuilder builder(heapProfiler, JSC::HeapSnapshotBuilder::SnapshotType::GCDebuggingSnapshot); + builder.buildSnapshot(); + + WTF::String jsonString = builder.json(); + if (jsonString.isEmpty()) + return "ERROR: Failed to generate heap snapshot"_s; + + auto jsonValue = JSON::Value::parseJSON(jsonString); + if (!jsonValue) + return "ERROR: Failed to parse heap snapshot JSON"_s; + + auto jsonObject = jsonValue->asObject(); + if (!jsonObject) + return "ERROR: Heap snapshot JSON is not an object"_s; + + // Determine format + WTF::String snapshotType = jsonObject->getString("type"_s); + bool isGCDebugging = snapshotType == "GCDebugging"_s; + int nodeStride = isGCDebugging ? 7 : 4; + + // Parse string tables + WTF::Vector classNames; + WTF::Vector edgeTypes; + WTF::Vector edgeNames; + WTF::Vector labels; + + auto parseStringArray = [](RefPtr arr, WTF::Vector& out) { + if (!arr) + return; + // Note: JSON::Array::get() returns Ref which is always valid + for (size_t i = 0; i < arr->length(); i++) { + out.append(arr->get(i)->asString()); + } + }; + + parseStringArray(jsonObject->getArray("nodeClassNames"_s), classNames); + parseStringArray(jsonObject->getArray("edgeTypes"_s), edgeTypes); + parseStringArray(jsonObject->getArray("edgeNames"_s), edgeNames); + parseStringArray(jsonObject->getArray("labels"_s), labels); + + // Parse nodes + WTF::Vector nodes; + NodeIdHashMap idToIndex; + size_t totalHeapSize = 0; + + auto nodesArray = jsonObject->getArray("nodes"_s); + if (nodesArray) { + size_t nodeCount = nodesArray->length() / nodeStride; + nodes.reserveCapacity(nodeCount); + + for (size_t i = 0; i < nodeCount; i++) { + NodeData node; + size_t offset = i * nodeStride; + + // Use asDouble() to get full integer range for id and size (which can exceed int range) + // Note: JSON::Array::get() returns Ref which is always valid + double dblVal = 0; + nodesArray->get(offset + 0)->asDouble(dblVal); + node.id = static_cast(dblVal); + + dblVal = 0; + nodesArray->get(offset + 1)->asDouble(dblVal); + node.size = static_cast(dblVal); + + int intVal = 0; + nodesArray->get(offset + 2)->asInteger(intVal); + node.classNameIndex = intVal; + + intVal = 0; + nodesArray->get(offset + 3)->asInteger(intVal); + node.flags = intVal; + node.isInternal = (node.flags & 1) != 0; + + if (isGCDebugging && nodeStride >= 7) { + intVal = 0; + nodesArray->get(offset + 4)->asInteger(intVal); + node.labelIndex = intVal; + } + + totalHeapSize += node.size; + idToIndex.set(node.id, nodes.size()); + nodes.append(node); + } + } + + // Parse edges + WTF::Vector edges; + auto edgesArray = jsonObject->getArray("edges"_s); + if (edgesArray) { + size_t edgeCount = edgesArray->length() / 4; + edges.reserveCapacity(edgeCount); + + for (size_t i = 0; i < edgeCount; i++) { + EdgeData edge; + size_t offset = i * 4; + + // Use asDouble() to get full integer range for IDs + // Note: JSON::Array::get() returns Ref which is always valid + double dblVal = 0; + edgesArray->get(offset + 0)->asDouble(dblVal); + edge.fromId = static_cast(dblVal); + + dblVal = 0; + edgesArray->get(offset + 1)->asDouble(dblVal); + edge.toId = static_cast(dblVal); + + int intVal = 0; + edgesArray->get(offset + 2)->asInteger(intVal); + edge.typeIndex = intVal; + + intVal = 0; + edgesArray->get(offset + 3)->asInteger(intVal); + edge.dataIndex = intVal; + + edges.append(edge); + } + } + + // Parse roots + // Note: JSON::Array::get() returns Ref which is always valid + NodeIdHashSet gcRootIds; + auto rootsArray = jsonObject->getArray("roots"_s); + if (rootsArray) { + for (size_t i = 0; i < rootsArray->length(); i += 3) { + double dblVal = 0; + rootsArray->get(i)->asDouble(dblVal); + uint64_t nodeId = static_cast(dblVal); + gcRootIds.add(nodeId); + auto it = idToIndex.find(nodeId); + if (it != idToIndex.end()) { + nodes[it->value].isGCRoot = true; + } + } + } + + // Build edge maps for efficient traversal + NodeIdHashMap> outgoingEdges; + NodeIdHashMap> incomingEdges; + for (size_t i = 0; i < edges.size(); i++) { + outgoingEdges.ensure(edges[i].fromId, [] { return WTF::Vector(); }).iterator->value.append(i); + incomingEdges.ensure(edges[i].toId, [] { return WTF::Vector(); }).iterator->value.append(i); + } + + // ============================================================ + // DOMINATOR TREE CALCULATION + // Based on: K. Cooper, T. Harvey and K. Kennedy + // "A Simple, Fast Dominance Algorithm" + // ============================================================ + + size_t nodeCount = nodes.size(); + if (nodeCount == 0) { + return "# Bun Heap Profile\n\nError: No heap profile nodes found. The heap snapshot may be empty or malformed.\n"_s; + } + + // Build nodeOrdinal (index) to nodeId mapping + WTF::Vector ordinalToId(nodeCount); + for (size_t i = 0; i < nodeCount; i++) { + ordinalToId[i] = nodes[i].id; + } + + // Step 1: Build post-order indexes via DFS from root (node 0) + WTF::Vector nodeOrdinalToPostOrderIndex(nodeCount); + WTF::Vector postOrderIndexToNodeOrdinal(nodeCount); + + // DFS using explicit stack + WTF::Vector stackNodes(nodeCount); + WTF::Vector stackEdgeIdx(nodeCount); + WTF::Vector visited(nodeCount, 0); + + uint32_t postOrderIndex = 0; + int stackTop = 0; + + // Start from root node (ordinal 0) + stackNodes[0] = 0; + stackEdgeIdx[0] = 0; + visited[0] = 1; + + while (stackTop >= 0) { + uint32_t nodeOrdinal = stackNodes[stackTop]; + uint64_t nodeId = ordinalToId[nodeOrdinal]; + + auto outIt = outgoingEdges.find(nodeId); + size_t& edgeIdx = stackEdgeIdx[stackTop]; + + bool foundChild = false; + if (outIt != outgoingEdges.end()) { + while (edgeIdx < outIt->value.size()) { + size_t currentEdgeIdx = outIt->value[edgeIdx]; + edgeIdx++; + + uint64_t toId = edges[currentEdgeIdx].toId; + auto toIt = idToIndex.find(toId); + if (toIt == idToIndex.end()) + continue; + + uint32_t toOrdinal = toIt->value; + if (visited[toOrdinal]) + continue; + + // Push child onto stack + visited[toOrdinal] = 1; + stackTop++; + stackNodes[stackTop] = toOrdinal; + stackEdgeIdx[stackTop] = 0; + foundChild = true; + break; + } + } + + if (!foundChild) { + // No more children, assign post-order index + nodeOrdinalToPostOrderIndex[nodeOrdinal] = postOrderIndex; + postOrderIndexToNodeOrdinal[postOrderIndex] = nodeOrdinal; + postOrderIndex++; + stackTop--; + } + } + + // Handle unvisited nodes (can happen with unreachable nodes) + if (postOrderIndex != nodeCount) { + // Root was last visited, revert + if (postOrderIndex > 0 && postOrderIndexToNodeOrdinal[postOrderIndex - 1] == 0) { + postOrderIndex--; + } + + // Visit unvisited nodes + for (uint32_t nodeOrdinal = 1; nodeOrdinal < nodeCount; ++nodeOrdinal) { + if (!visited[nodeOrdinal]) { + nodeOrdinalToPostOrderIndex[nodeOrdinal] = postOrderIndex; + postOrderIndexToNodeOrdinal[postOrderIndex] = nodeOrdinal; + postOrderIndex++; + } + } + + // Make sure root is last + if (!visited[0] || nodeOrdinalToPostOrderIndex[0] != nodeCount - 1) { + nodeOrdinalToPostOrderIndex[0] = postOrderIndex; + postOrderIndexToNodeOrdinal[postOrderIndex] = 0; + postOrderIndex++; + } + } + + // Step 2: Build dominator tree using Cooper-Harvey-Kennedy algorithm + uint32_t rootPostOrderIndex = nodeCount - 1; + uint32_t noEntry = nodeCount; + + WTF::Vector affected(nodeCount, 0); + WTF::Vector dominators(nodeCount, noEntry); + WTF::Vector nodeOrdinalToDominator(nodeCount, 0); + + // Root dominates itself + dominators[rootPostOrderIndex] = rootPostOrderIndex; + + // Mark root's children as affected and as GC roots + uint64_t rootId = ordinalToId[0]; + auto rootOutEdges = outgoingEdges.find(rootId); + if (rootOutEdges != outgoingEdges.end()) { + for (size_t edgeIdx : rootOutEdges->value) { + uint64_t toId = edges[edgeIdx].toId; + auto toIt = idToIndex.find(toId); + if (toIt != idToIndex.end()) { + uint32_t toOrdinal = toIt->value; + uint32_t toPostOrder = nodeOrdinalToPostOrderIndex[toOrdinal]; + affected[toPostOrder] = 1; + nodes[toOrdinal].isGCRoot = true; + // Also add to gcRootIds to keep it in sync with isGCRoot flag + gcRootIds.add(toId); + } + } + } + + // Iteratively compute dominators + bool changed = true; + while (changed) { + changed = false; + + for (int32_t postOrder = static_cast(rootPostOrderIndex) - 1; postOrder >= 0; --postOrder) { + if (!affected[postOrder]) + continue; + affected[postOrder] = 0; + + // Already dominated by root + if (dominators[postOrder] == rootPostOrderIndex) + continue; + + uint32_t newDominator = noEntry; + uint32_t nodeOrdinal = postOrderIndexToNodeOrdinal[postOrder]; + uint64_t nodeId = ordinalToId[nodeOrdinal]; + + // Check all incoming edges + auto inIt = incomingEdges.find(nodeId); + if (inIt != incomingEdges.end()) { + for (size_t edgeIdx : inIt->value) { + uint64_t fromId = edges[edgeIdx].fromId; + auto fromIt = idToIndex.find(fromId); + if (fromIt == idToIndex.end()) + continue; + + uint32_t fromOrdinal = fromIt->value; + uint32_t fromPostOrder = nodeOrdinalToPostOrderIndex[fromOrdinal]; + + if (dominators[fromPostOrder] == noEntry) + continue; + + if (newDominator == noEntry) { + newDominator = fromPostOrder; + } else { + // Find common dominator (intersect) + uint32_t finger1 = fromPostOrder; + uint32_t finger2 = newDominator; + // Guard against infinite loops with iteration limit + size_t maxIterations = nodeCount * 2; + size_t iterations = 0; + while (finger1 != finger2 && iterations < maxIterations) { + while (finger1 < finger2) { + finger1 = dominators[finger1]; + iterations++; + } + while (finger2 < finger1) { + finger2 = dominators[finger2]; + iterations++; + } + } + newDominator = finger1; + } + + if (newDominator == rootPostOrderIndex) + break; + } + } + + // Update if changed + if (newDominator != noEntry && dominators[postOrder] != newDominator) { + dominators[postOrder] = newDominator; + changed = true; + + // Mark children as affected + auto outIt = outgoingEdges.find(nodeId); + if (outIt != outgoingEdges.end()) { + for (size_t edgeIdx : outIt->value) { + uint64_t toId = edges[edgeIdx].toId; + auto toIt = idToIndex.find(toId); + if (toIt != idToIndex.end()) { + uint32_t toPostOrder = nodeOrdinalToPostOrderIndex[toIt->value]; + affected[toPostOrder] = 1; + } + } + } + } + } + } + + // Convert post-order dominators to node ordinals + for (uint32_t postOrder = 0; postOrder < nodeCount; ++postOrder) { + uint32_t nodeOrdinal = postOrderIndexToNodeOrdinal[postOrder]; + uint32_t domPostOrder = dominators[postOrder]; + uint32_t domOrdinal = (domPostOrder < nodeCount) ? postOrderIndexToNodeOrdinal[domPostOrder] : 0; + nodeOrdinalToDominator[nodeOrdinal] = domOrdinal; + } + + // Step 3: Calculate retained sizes by attributing size up the dominator tree + // First, set self size + for (size_t i = 0; i < nodeCount; i++) { + nodes[i].retainedSize = nodes[i].size; + } + + // Walk in post-order (children before parents) and add to dominator + for (uint32_t postOrder = 0; postOrder < nodeCount - 1; ++postOrder) { + uint32_t nodeOrdinal = postOrderIndexToNodeOrdinal[postOrder]; + uint32_t domOrdinal = nodeOrdinalToDominator[nodeOrdinal]; + nodes[domOrdinal].retainedSize += nodes[nodeOrdinal].retainedSize; + } + + // Build type statistics + WTF::HashMap typeStatsMap; + for (const auto& node : nodes) { + WTF::String className = (node.classNameIndex >= 0 && static_cast(node.classNameIndex) < classNames.size()) + ? classNames[node.classNameIndex] + : "(unknown)"_s; + + auto result = typeStatsMap.add(className, TypeStats()); + auto& stats = result.iterator->value; + if (result.isNewEntry) + stats.name = className; + stats.totalSize += node.size; + stats.totalRetainedSize += node.retainedSize; + stats.count++; + if (node.retainedSize > stats.largestRetained) { + stats.largestRetained = node.retainedSize; + stats.largestInstanceId = node.id; + } + } + + // Sort types by retained size + WTF::Vector sortedTypes; + for (auto& pair : typeStatsMap) + sortedTypes.append(pair.value); + std::sort(sortedTypes.begin(), sortedTypes.end(), [](const TypeStats& a, const TypeStats& b) { + return a.totalRetainedSize > b.totalRetainedSize; + }); + + // Find largest objects + WTF::Vector largestObjects; + for (size_t i = 0; i < nodes.size(); i++) + largestObjects.append(i); + std::sort(largestObjects.begin(), largestObjects.end(), [&nodes](size_t a, size_t b) { + return nodes[a].retainedSize > nodes[b].retainedSize; + }); + + // Helpers + auto getClassName = [&classNames](const NodeData& node) -> WTF::String { + if (node.classNameIndex >= 0 && static_cast(node.classNameIndex) < classNames.size()) + return classNames[node.classNameIndex]; + return "(unknown)"_s; + }; + + auto getEdgeType = [&edgeTypes](const EdgeData& edge) -> WTF::String { + if (edge.typeIndex >= 0 && static_cast(edge.typeIndex) < edgeTypes.size()) + return edgeTypes[edge.typeIndex]; + return "?"_s; + }; + + auto getEdgeName = [&edgeNames, &edgeTypes](const EdgeData& edge) -> WTF::String { + WTF::String edgeType; + if (edge.typeIndex >= 0 && static_cast(edge.typeIndex) < edgeTypes.size()) + edgeType = edgeTypes[edge.typeIndex]; + + if (edgeType == "Property"_s || edgeType == "Variable"_s) { + if (edge.dataIndex >= 0 && static_cast(edge.dataIndex) < edgeNames.size()) + return edgeNames[edge.dataIndex]; + } else if (edgeType == "Index"_s) { + return makeString("["_s, WTF::String::number(edge.dataIndex), "]"_s); + } + return ""_s; + }; + + auto getNodeLabel = [&labels](const NodeData& node) -> WTF::String { + if (node.labelIndex >= 0 && static_cast(node.labelIndex) < labels.size()) + return labels[node.labelIndex]; + return ""_s; + }; + + // Build output + WTF::StringBuilder output; + + // ==================== HEADER ==================== + output.append("# Bun Heap Profile\n\n"_s); + output.append("Generated by `bun --heap-prof-md`. This profile contains complete heap data in markdown format.\n\n"_s); + output.append("**Quick Search Commands:**\n"_s); + output.append("```bash\n"_s); + output.append("grep '| `Function`' file.md # Find all Function objects\n"_s); + output.append("grep 'gcroot=1' file.md # Find all GC roots\n"_s); + output.append("grep '| 12345 |' file.md # Find object #12345 or edges involving it\n"_s); + output.append("```\n\n"_s); + output.append("---\n\n"_s); + + // ==================== SUMMARY ==================== + output.append("## Summary\n\n"_s); + output.append("| Metric | Value |\n"_s); + output.append("|--------|------:|\n"_s); + output.append("| Total Heap Size | "_s); + output.append(formatBytes(totalHeapSize)); + output.append(" ("_s); + output.append(WTF::String::number(totalHeapSize)); + output.append(" bytes) |\n"_s); + output.append("| Total Objects | "_s); + output.append(WTF::String::number(nodes.size())); + output.append(" |\n"_s); + output.append("| Total Edges | "_s); + output.append(WTF::String::number(edges.size())); + output.append(" |\n"_s); + output.append("| Unique Types | "_s); + output.append(WTF::String::number(sortedTypes.size())); + output.append(" |\n"_s); + output.append("| GC Roots | "_s); + output.append(WTF::String::number(gcRootIds.size())); + output.append(" |\n\n"_s); + + // ==================== TOP TYPES ==================== + output.append("## Top 50 Types by Retained Size\n\n"_s); + output.append("| Rank | Type | Count | Self Size | Retained Size | Largest Instance |\n"_s); + output.append("|-----:|------|------:|----------:|--------------:|-----------------:|\n"_s); + + size_t rank = 1; + for (const auto& stats : sortedTypes) { + if (rank > 50) + break; + + output.append("| "_s); + output.append(WTF::String::number(rank)); + output.append(" | `"_s); + output.append(escapeString(stats.name)); + output.append("` | "_s); + output.append(WTF::String::number(stats.count)); + output.append(" | "_s); + output.append(formatBytes(stats.totalSize)); + output.append(" | "_s); + output.append(formatBytes(stats.totalRetainedSize)); + output.append(" | "_s); + output.append(formatBytes(stats.largestRetained)); + output.append(" |\n"_s); + rank++; + } + output.append("\n"_s); + + // ==================== LARGEST OBJECTS ==================== + output.append("## Top 50 Largest Objects\n\n"_s); + output.append("Objects that retain the most memory (potential memory leak sources):\n\n"_s); + output.append("| Rank | ID | Type | Self Size | Retained Size | Out-Edges | In-Edges |\n"_s); + output.append("|-----:|---:|------|----------:|--------------:|----------:|---------:|\n"_s); + + for (size_t i = 0; i < 50 && i < largestObjects.size(); i++) { + const auto& node = nodes[largestObjects[i]]; + size_t outCount = 0, inCount = 0; + auto outIt = outgoingEdges.find(node.id); + if (outIt != outgoingEdges.end()) + outCount = outIt->value.size(); + auto inIt = incomingEdges.find(node.id); + if (inIt != incomingEdges.end()) + inCount = inIt->value.size(); + + output.append("| "_s); + output.append(WTF::String::number(i + 1)); + output.append(" | "_s); + output.append(WTF::String::number(node.id)); + output.append(" | `"_s); + output.append(escapeString(getClassName(node))); + output.append("` | "_s); + output.append(formatBytes(node.size)); + output.append(" | "_s); + output.append(formatBytes(node.retainedSize)); + output.append(" | "_s); + output.append(WTF::String::number(outCount)); + output.append(" | "_s); + output.append(WTF::String::number(inCount)); + output.append(" |\n"_s); + } + output.append("\n"_s); + + // ==================== RETAINER CHAINS ==================== + output.append("## Retainer Chains\n\n"_s); + output.append("How the top 20 largest objects are kept alive (path from GC root to object):\n\n"_s); + + for (size_t i = 0; i < 20 && i < largestObjects.size(); i++) { + const auto& node = nodes[largestObjects[i]]; + output.append("### "_s); + output.append(WTF::String::number(i + 1)); + output.append(". Object #"_s); + output.append(WTF::String::number(node.id)); + output.append(" - `"_s); + output.append(escapeString(getClassName(node))); + output.append("` ("_s); + output.append(formatBytes(node.retainedSize)); + output.append(" retained)\n\n"_s); + + // BFS to find path to GC root + // We traverse from node.id upward through retainers (incoming edges) + // parent[X] = Y means "X is retained by Y" (Y is X's retainer) + // retainerEdge[X] = edgeIdx means "edges[edgeIdx] is the edge FROM parent[X] TO X" + NodeIdHashMap retainer; + NodeIdHashMap retainerEdge; + WTF::Vector queue; + size_t queueIdx = 0; + queue.append(node.id); + retainer.set(node.id, node.id); // sentinel + + bool foundRootFound = false; + uint64_t foundRootId = 0; + while (queueIdx < queue.size() && !foundRootFound) { + uint64_t current = queue[queueIdx++]; + if (gcRootIds.contains(current) && current != node.id) { + foundRootFound = true; + foundRootId = current; + break; + } + auto it = incomingEdges.find(current); + if (it != incomingEdges.end()) { + // Only set retainer for current once (first valid retainer wins) + bool currentHasRetainer = (retainer.get(current) != current); + for (size_t edgeIdx : it->value) { + uint64_t retainerId = edges[edgeIdx].fromId; + if (!retainer.contains(retainerId)) { + // Only set current's retainer if not already set + if (!currentHasRetainer) { + retainer.set(current, retainerId); + retainerEdge.set(current, edgeIdx); + currentHasRetainer = true; + } + // Mark retainerId as visited and add to queue + retainer.set(retainerId, retainerId); // sentinel, will be updated when we find its retainer + queue.append(retainerId); + } + } + } + } + + output.append("```\n"_s); + if (foundRootFound) { + // Build path from node.id to foundRootId + WTF::Vector path; + uint64_t current = node.id; + while (current != foundRootId && retainer.contains(current)) { + path.append(current); + uint64_t next = retainer.get(current); + if (next == current) break; // sentinel or no retainer + current = next; + } + path.append(foundRootId); + + // Print path from root to node (reverse order) + for (size_t j = path.size(); j > 0; j--) { + uint64_t nodeId = path[j - 1]; + auto nodeIt = idToIndex.find(nodeId); + if (nodeIt == idToIndex.end()) + continue; + const auto& pathNode = nodes[nodeIt->value]; + + for (size_t indent = 0; indent < path.size() - j; indent++) + output.append(" "_s); + + output.append(getClassName(pathNode)); + output.append("#"_s); + output.append(WTF::String::number(nodeId)); + if (pathNode.isGCRoot) + output.append(" [ROOT]"_s); + output.append(" ("_s); + output.append(formatBytes(pathNode.size)); + output.append(")"_s); + + // Show edge to child (path[j-2]) + if (j > 1) { + uint64_t childId = path[j - 2]; + auto edgeIt = retainerEdge.find(childId); + if (edgeIt != retainerEdge.end()) { + WTF::String edgeName = getEdgeName(edges[edgeIt->value]); + if (!edgeName.isEmpty()) { + output.append(" ."_s); + output.append(edgeName); + } + output.append(" -> "_s); + } + } + output.append("\n"_s); + } + } else if (node.isGCRoot) { + output.append(getClassName(node)); + output.append("#"_s); + output.append(WTF::String::number(node.id)); + output.append(" [ROOT] (this object is a GC root)\n"_s); + } else { + output.append("(no path to GC root found)\n"_s); + } + output.append("```\n\n"_s); + } + + // ==================== GC ROOTS ==================== + output.append("## GC Roots\n\n"_s); + output.append("Objects directly held by the runtime (prevent garbage collection):\n\n"_s); + output.append("| ID | Type | Size | Retained | Label |\n"_s); + output.append("|---:|------|-----:|---------:|-------|\n"_s); + + size_t rootCount = 0; + for (const auto& node : nodes) { + if (node.isGCRoot && rootCount < 100) { + output.append("| "_s); + output.append(WTF::String::number(node.id)); + output.append(" | `"_s); + output.append(escapeString(getClassName(node))); + output.append("` | "_s); + output.append(formatBytes(node.size)); + output.append(" | "_s); + output.append(formatBytes(node.retainedSize)); + output.append(" | "_s); + WTF::String label = getNodeLabel(node); + if (!label.isEmpty()) + output.append(escapeString(label.left(50))); + output.append(" |\n"_s); + rootCount++; + } + } + if (gcRootIds.size() > 100) { + output.append("\n*... and "_s); + output.append(WTF::String::number(gcRootIds.size() - 100)); + output.append(" more GC roots*\n"_s); + } + output.append("\n"_s); + + // ==================== ALL NODES ==================== + output.append("## All Objects\n\n"_s); + output.append("
\nClick to expand "_s); + output.append(WTF::String::number(nodes.size())); + output.append(" objects (searchable with grep)\n\n"_s); + output.append("| ID | Type | Size | Retained | Flags | Label |\n"_s); + output.append("|---:|------|-----:|---------:|-------|-------|\n"_s); + + for (const auto& node : nodes) { + output.append("| "_s); + output.append(WTF::String::number(node.id)); + output.append(" | `"_s); + output.append(escapeString(getClassName(node))); + output.append("` | "_s); + output.append(WTF::String::number(node.size)); + output.append(" | "_s); + output.append(WTF::String::number(node.retainedSize)); + output.append(" | "_s); + if (node.isGCRoot) + output.append("gcroot=1 "_s); + if (node.isInternal) + output.append("internal=1"_s); + output.append(" | "_s); + WTF::String label = getNodeLabel(node); + if (!label.isEmpty()) { + WTF::String displayLabel = label.length() > 40 ? makeString(label.left(37), "..."_s) : label; + output.append(escapeString(displayLabel)); + } + output.append(" |\n"_s); + } + output.append("\n
\n\n"_s); + + // ==================== ALL EDGES ==================== + output.append("## All Edges\n\n"_s); + output.append("
\nClick to expand "_s); + output.append(WTF::String::number(edges.size())); + output.append(" edges (object reference graph)\n\n"_s); + output.append("| From | To | Type | Name |\n"_s); + output.append("|-----:|---:|------|------|\n"_s); + + for (const auto& edge : edges) { + output.append("| "_s); + output.append(WTF::String::number(edge.fromId)); + output.append(" | "_s); + output.append(WTF::String::number(edge.toId)); + output.append(" | "_s); + output.append(getEdgeType(edge)); + output.append(" | "_s); + WTF::String edgeName = getEdgeName(edge); + if (!edgeName.isEmpty()) + output.append(escapeString(edgeName)); + output.append(" |\n"_s); + } + output.append("\n
\n\n"_s); + + // ==================== STRING VALUES ==================== + output.append("## String Values\n\n"_s); + output.append("String objects (useful for identifying leak sources by content):\n\n"_s); + output.append("
\nClick to expand string values\n\n"_s); + output.append("| ID | Size | Value |\n"_s); + output.append("|---:|-----:|-------|\n"_s); + + for (const auto& node : nodes) { + WTF::String className = getClassName(node); + if (className == "string"_s || className == "String"_s) { + WTF::String label = getNodeLabel(node); + output.append("| "_s); + output.append(WTF::String::number(node.id)); + output.append(" | "_s); + output.append(WTF::String::number(node.size)); + output.append(" | "_s); + if (!label.isEmpty()) { + WTF::String displayLabel = label.length() > 100 ? makeString(label.left(97), "..."_s) : label; + output.append("`"_s); + output.append(escapeString(displayLabel)); + output.append("`"_s); + } + output.append(" |\n"_s); + } + } + output.append("\n
\n\n"_s); + + // ==================== TYPE STATISTICS ==================== + output.append("## Complete Type Statistics\n\n"_s); + output.append("
\nClick to expand all "_s); + output.append(WTF::String::number(sortedTypes.size())); + output.append(" types\n\n"_s); + output.append("| Type | Count | Self Size | Retained Size | Largest ID |\n"_s); + output.append("|------|------:|----------:|--------------:|-----------:|\n"_s); + + for (const auto& stats : sortedTypes) { + output.append("| `"_s); + output.append(escapeString(stats.name)); + output.append("` | "_s); + output.append(WTF::String::number(stats.count)); + output.append(" | "_s); + output.append(WTF::String::number(stats.totalSize)); + output.append(" | "_s); + output.append(WTF::String::number(stats.totalRetainedSize)); + output.append(" | "_s); + output.append(WTF::String::number(stats.largestInstanceId)); + output.append(" |\n"_s); + } + output.append("\n
\n\n"_s); + + // ==================== EDGE NAMES ==================== + output.append("## Property Names\n\n"_s); + output.append("
\nClick to expand all "_s); + output.append(WTF::String::number(edgeNames.size())); + output.append(" property/variable names\n\n"_s); + output.append("| Index | Name |\n"_s); + output.append("|------:|------|\n"_s); + + for (size_t i = 0; i < edgeNames.size(); i++) { + if (!edgeNames[i].isEmpty()) { + output.append("| "_s); + output.append(WTF::String::number(i)); + output.append(" | `"_s); + output.append(escapeString(edgeNames[i])); + output.append("` |\n"_s); + } + } + output.append("\n
\n\n"_s); + + // ==================== FOOTER ==================== + output.append("---\n\n"_s); + output.append("*End of heap profile*\n"_s); + + return output.toString(); +} + +WTF::String generateHeapSnapshotV8(JSC::VM& vm) +{ + vm.ensureHeapProfiler(); + auto& heapProfiler = *vm.heapProfiler(); + heapProfiler.clearSnapshots(); + + JSC::BunV8HeapSnapshotBuilder builder(heapProfiler); + return builder.json(); +} + +} // namespace Bun + +extern "C" BunString Bun__generateHeapProfile(JSC::VM* vm) +{ + WTF::String result = Bun::generateHeapProfile(*vm); + return Bun::toStringRef(result); +} + +extern "C" BunString Bun__generateHeapSnapshotV8(JSC::VM* vm) +{ + WTF::String result = Bun::generateHeapSnapshotV8(*vm); + return Bun::toStringRef(result); +} diff --git a/src/bun.js/bindings/BunHeapProfiler.h b/src/bun.js/bindings/BunHeapProfiler.h new file mode 100644 index 0000000000..cb3035b534 --- /dev/null +++ b/src/bun.js/bindings/BunHeapProfiler.h @@ -0,0 +1,17 @@ +#pragma once + +#include "root.h" +#include + +namespace JSC { +class VM; +} + +namespace Bun { + +// Generate a Claude-friendly text-based heap profile +// This format is designed specifically for analysis by LLMs with grep/sed/awk tools +// The output is hierarchical but with clear section markers for easy navigation +WTF::String generateHeapProfile(JSC::VM& vm); + +} // namespace Bun diff --git a/src/bun.js/bindings/BunHeapProfiler.zig b/src/bun.js/bindings/BunHeapProfiler.zig new file mode 100644 index 0000000000..9b797b6c8b --- /dev/null +++ b/src/bun.js/bindings/BunHeapProfiler.zig @@ -0,0 +1,110 @@ +pub const HeapProfilerConfig = struct { + name: []const u8, + dir: []const u8, + text_format: bool, +}; + +// C++ function declarations +extern fn Bun__generateHeapProfile(vm: *jsc.VM) bun.String; +extern fn Bun__generateHeapSnapshotV8(vm: *jsc.VM) bun.String; + +pub fn generateAndWriteProfile(vm: *jsc.VM, config: HeapProfilerConfig) !void { + const profile_string = if (config.text_format) + Bun__generateHeapProfile(vm) + else + Bun__generateHeapSnapshotV8(vm); + defer profile_string.deref(); + + if (profile_string.isEmpty()) { + // No profile data generated + return; + } + + const profile_slice = profile_string.toUTF8(bun.default_allocator); + defer profile_slice.deinit(); + + // Determine the output path using AutoAbsPath + var path_buf: bun.AutoAbsPath = .initTopLevelDir(); + defer path_buf.deinit(); + + try buildOutputPath(&path_buf, config); + + // Convert to OS-specific path (UTF-16 on Windows, UTF-8 elsewhere) + var path_buf_os: bun.OSPathBuffer = undefined; + const output_path_os: bun.OSPathSliceZ = if (bun.Environment.isWindows) + bun.strings.convertUTF8toUTF16InBufferZ(&path_buf_os, path_buf.sliceZ()) + else + path_buf.sliceZ(); + + // Write the profile to disk using bun.sys.File.writeFile + const result = bun.sys.File.writeFile(bun.FD.cwd(), output_path_os, profile_slice.slice()); + if (result.asErr()) |err| { + // If we got ENOENT, PERM, or ACCES, try creating the directory and retry + const errno = err.getErrno(); + if (errno == .NOENT or errno == .PERM or errno == .ACCES) { + // Derive directory from the absolute output path + const abs_path = path_buf.slice(); + const dir_path = bun.path.dirname(abs_path, .auto); + if (dir_path.len > 0) { + bun.FD.cwd().makePath(u8, dir_path) catch {}; + // Retry write + const retry_result = bun.sys.File.writeFile(bun.FD.cwd(), output_path_os, profile_slice.slice()); + if (retry_result.asErr()) |_| { + return error.WriteFailed; + } + } else { + return error.WriteFailed; + } + } else { + return error.WriteFailed; + } + } + + // Print message to stderr to let user know where the profile was written + Output.prettyErrorln("Heap profile written to: {s}", .{path_buf.slice()}); + Output.flush(); +} + +fn buildOutputPath(path: *bun.AutoAbsPath, config: HeapProfilerConfig) !void { + // Generate filename + var filename_buf: bun.PathBuffer = undefined; + const filename = if (config.name.len > 0) + config.name + else + try generateDefaultFilename(&filename_buf, config.text_format); + + // Append directory if specified + if (config.dir.len > 0) { + path.append(config.dir); + } + + // Append filename + path.append(filename); +} + +fn generateDefaultFilename(buf: *bun.PathBuffer, text_format: bool) ![]const u8 { + // Generate filename like: + // - Markdown format: Heap.{timestamp}.{pid}.md + // - V8 format: Heap.{timestamp}.{pid}.heapsnapshot + const timespec = bun.timespec.now(.force_real_time); + const pid = if (bun.Environment.isWindows) + std.os.windows.GetCurrentProcessId() + else + std.c.getpid(); + + const epoch_microseconds: u64 = @intCast(timespec.sec *% 1_000_000 +% @divTrunc(timespec.nsec, 1000)); + + const extension = if (text_format) "md" else "heapsnapshot"; + + return try std.fmt.bufPrint(buf, "Heap.{d}.{d}.{s}", .{ + epoch_microseconds, + pid, + extension, + }); +} + +const std = @import("std"); + +const bun = @import("bun"); +const Output = bun.Output; +const jsc = bun.jsc; diff --git a/src/cli.zig b/src/cli.zig index 3a0819a73f..68eac17b15 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -390,11 +390,17 @@ pub const Command = struct { console_depth: ?u16 = null, cpu_prof: struct { enabled: bool = false, - name: ?[]const u8 = null, - dir: ?[]const u8 = null, + name: []const u8 = "", + dir: []const u8 = "", md_format: bool = false, json_format: bool = false, } = .{}, + heap_prof: struct { + enabled: bool = false, + text_format: bool = false, + name: []const u8 = "", + dir: []const u8 = "", + } = .{}, }; var global_cli_ctx: Context = undefined; diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index f3c3c299da..1c00f0ccb1 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -91,6 +91,10 @@ pub const runtime_params_ = [_]ParamType{ clap.parseParam("--cpu-prof-name Specify the name of the CPU profile file") catch unreachable, clap.parseParam("--cpu-prof-dir Specify the directory where the CPU profile will be saved") catch unreachable, clap.parseParam("--cpu-prof-md Output CPU profile in markdown format (grep-friendly, designed for LLM analysis)") catch unreachable, + clap.parseParam("--heap-prof Generate V8 heap snapshot on exit (.heapsnapshot)") catch unreachable, + clap.parseParam("--heap-prof-name Specify the name of the heap profile file") catch unreachable, + clap.parseParam("--heap-prof-dir Specify the directory where the heap profile will be saved") catch unreachable, + clap.parseParam("--heap-prof-md Generate markdown heap profile on exit (for CLI analysis)") catch unreachable, clap.parseParam("--if-present Exit without an error if the entrypoint does not exist") catch unreachable, clap.parseParam("--no-install Disable auto install in the Bun runtime") catch unreachable, clap.parseParam("--install Configure auto-install behavior. One of \"auto\" (default, auto-installs when no node_modules), \"fallback\" (missing packages only), \"force\" (always).") catch unreachable, @@ -863,6 +867,39 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C } } + const heap_prof_v8 = args.flag("--heap-prof"); + const heap_prof_md = args.flag("--heap-prof-md"); + + if (heap_prof_v8 and heap_prof_md) { + // Both flags specified - warn and use markdown format + Output.warn("Both --heap-prof and --heap-prof-md specified; using --heap-prof-md (markdown format)", .{}); + ctx.runtime_options.heap_prof.enabled = true; + ctx.runtime_options.heap_prof.text_format = true; + if (args.option("--heap-prof-name")) |name| { + ctx.runtime_options.heap_prof.name = name; + } + if (args.option("--heap-prof-dir")) |dir| { + ctx.runtime_options.heap_prof.dir = dir; + } + } else if (heap_prof_v8 or heap_prof_md) { + ctx.runtime_options.heap_prof.enabled = true; + ctx.runtime_options.heap_prof.text_format = heap_prof_md; + if (args.option("--heap-prof-name")) |name| { + ctx.runtime_options.heap_prof.name = name; + } + if (args.option("--heap-prof-dir")) |dir| { + ctx.runtime_options.heap_prof.dir = dir; + } + } else { + // Warn if --heap-prof-name or --heap-prof-dir is used without --heap-prof or --heap-prof-md + if (args.option("--heap-prof-name")) |_| { + Output.warn("--heap-prof-name requires --heap-prof or --heap-prof-md to be enabled", .{}); + } + if (args.option("--heap-prof-dir")) |_| { + Output.warn("--heap-prof-dir requires --heap-prof or --heap-prof-md to be enabled", .{}); + } + } + if (args.flag("--no-deprecation")) { Bun__Node__ProcessNoDeprecation = true; } diff --git a/test/cli/heap-prof.test.ts b/test/cli/heap-prof.test.ts new file mode 100644 index 0000000000..246124740a --- /dev/null +++ b/test/cli/heap-prof.test.ts @@ -0,0 +1,233 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "path"; + +const testScript = `const arr = []; for (let i = 0; i < 100; i++) arr.push({ x: i, y: "hello" + i }); console.log("done");`; + +test("--heap-prof generates V8 heap snapshot on exit", async () => { + using dir = tempDir("heap-prof-v8-test", {}); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--heap-prof", "-e", testScript], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("done"); + expect(stderr).toContain("Heap profile written to:"); + expect(exitCode).toBe(0); + + // Find the heap snapshot file (V8 format) + const glob = new Bun.Glob("Heap.*.heapsnapshot"); + const files = Array.from(glob.scanSync({ cwd: String(dir) })); + expect(files.length).toBeGreaterThan(0); + + // Read and validate the heap snapshot content (should be valid JSON in V8 format) + const profilePath = join(String(dir), files[0]); + const content = await Bun.file(profilePath).text(); + + // V8 heap snapshot format is JSON with specific structure + const snapshot = JSON.parse(content); + expect(snapshot).toHaveProperty("snapshot"); + expect(snapshot).toHaveProperty("nodes"); + expect(snapshot).toHaveProperty("edges"); + expect(snapshot).toHaveProperty("strings"); +}); + +test("--heap-prof-md generates markdown heap profile on exit", async () => { + using dir = tempDir("heap-prof-md-test", {}); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--heap-prof-md", "-e", testScript], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("done"); + expect(stderr).toContain("Heap profile written to:"); + expect(exitCode).toBe(0); + + // Find the heap profile file (markdown format) + const glob = new Bun.Glob("Heap.*.md"); + const files = Array.from(glob.scanSync({ cwd: String(dir) })); + expect(files.length).toBeGreaterThan(0); + + // Read and validate the heap profile content + const profilePath = join(String(dir), files[0]); + const content = await Bun.file(profilePath).text(); + + // Check for markdown headers + expect(content).toContain("# Bun Heap Profile"); + expect(content).toContain("## Summary"); + expect(content).toContain("## Top 50 Types by Retained Size"); + expect(content).toContain("## Top 50 Largest Objects"); + expect(content).toContain("## Retainer Chains"); + expect(content).toContain("## GC Roots"); + + // Check for summary table structure + expect(content).toContain("| Metric | Value |"); + expect(content).toContain("| Total Heap Size |"); + expect(content).toContain("| Total Objects |"); + expect(content).toContain("| Unique Types |"); + expect(content).toContain("| GC Roots |"); + + // Check for table structure in types section + expect(content).toContain("| Rank | Type | Count | Self Size | Retained Size |"); + + // Check for collapsible sections + expect(content).toContain("
"); + expect(content).toContain(""); + + // Check for All Objects table format + expect(content).toContain("## All Objects"); + expect(content).toContain("| ID | Type | Size | Retained | Flags | Label |"); + + // Check for All Edges table format + expect(content).toContain("## All Edges"); + expect(content).toContain("| From | To | Type | Name |"); + + // Check for Type Statistics table format + expect(content).toContain("## Complete Type Statistics"); + expect(content).toContain("| Type | Count | Self Size | Retained Size | Largest ID |"); +}); + +test("--heap-prof-dir specifies output directory for V8 format", async () => { + using dir = tempDir("heap-prof-dir-test", { + "profiles": {}, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--heap-prof", "--heap-prof-dir", "profiles", "-e", `console.log("hello");`], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("hello"); + expect(stderr).toContain("Heap profile written to:"); + // Check for "profiles" directory in path (handles both / and \ separators) + expect(stderr).toMatch(/profiles[/\\]/); + expect(exitCode).toBe(0); + + // Check the profile is in the specified directory + const glob = new Bun.Glob("Heap.*.heapsnapshot"); + const files = Array.from(glob.scanSync({ cwd: join(String(dir), "profiles") })); + expect(files.length).toBeGreaterThan(0); +}); + +test("--heap-prof-dir specifies output directory for markdown format", async () => { + using dir = tempDir("heap-prof-md-dir-test", { + "profiles": {}, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--heap-prof-md", "--heap-prof-dir", "profiles", "-e", `console.log("hello");`], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("hello"); + expect(stderr).toContain("Heap profile written to:"); + // Check for "profiles" directory in path (handles both / and \ separators) + expect(stderr).toMatch(/profiles[/\\]/); + expect(exitCode).toBe(0); + + // Check the profile is in the specified directory + const glob = new Bun.Glob("Heap.*.md"); + const files = Array.from(glob.scanSync({ cwd: join(String(dir), "profiles") })); + expect(files.length).toBeGreaterThan(0); +}); + +test("--heap-prof-name specifies output filename", async () => { + using dir = tempDir("heap-prof-name-test", {}); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--heap-prof", "--heap-prof-name", "my-profile.heapsnapshot", "-e", `console.log("hello");`], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("hello"); + expect(stderr).toContain("Heap profile written to:"); + expect(stderr).toContain("my-profile.heapsnapshot"); + expect(exitCode).toBe(0); + + // Check the profile exists with the specified name + const profilePath = join(String(dir), "my-profile.heapsnapshot"); + expect(Bun.file(profilePath).size).toBeGreaterThan(0); +}); + +test("--heap-prof-name and --heap-prof-dir work together", async () => { + using dir = tempDir("heap-prof-both-test", { + "output": {}, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--heap-prof", + "--heap-prof-dir", + "output", + "--heap-prof-name", + "custom.heapsnapshot", + "-e", + `console.log("hello");`, + ], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("hello"); + expect(stderr).toContain("Heap profile written to:"); + expect(exitCode).toBe(0); + + // Check the profile exists in the specified location + const profilePath = join(String(dir), "output", "custom.heapsnapshot"); + expect(Bun.file(profilePath).size).toBeGreaterThan(0); +}); + +test("--heap-prof-name without --heap-prof or --heap-prof-md shows warning", async () => { + using dir = tempDir("heap-prof-warn-test", {}); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--heap-prof-name", "test.heapsnapshot", "-e", `console.log("hello");`], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("hello"); + expect(stderr).toContain("--heap-prof-name requires --heap-prof or --heap-prof-md to be enabled"); + expect(exitCode).toBe(0); + + // No profile should be generated + const glob = new Bun.Glob("*.heap*"); + const files = Array.from(glob.scanSync({ cwd: String(dir) })); + expect(files.length).toBe(0); +}); diff --git a/test/internal/ban-limits.json b/test/internal/ban-limits.json index 4219d0eb41..9f9902af9b 100644 --- a/test/internal/ban-limits.json +++ b/test/internal/ban-limits.json @@ -7,7 +7,7 @@ ".arguments_old(": 263, ".jsBoolean(false)": 0, ".jsBoolean(true)": 0, - ".stdDir()": 42, + ".stdDir()": 41, ".stdFile()": 16, "// autofix": 148, ": [^=]+= undefined,$": 256,