moonheap

    Portable V8 heap snapshot graph analysis, retaining paths and explicit snapshot comparisons in MoonBit

    heap
    v8
    memory
    profiling
    diagnostics
    Download zip
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    3 hours ago
    Downloads
    6

    No dependencies

    #MoonHeap

    找出对象被谁保留,解释程序为什么越来越占内存。

    MoonHeap 是用 MoonBit 编写的离线 V8 堆快照分析库,附带命令行工具和本地网页。它把「采集基线 → 复现增长 → 清理 → 找出存活对象 → 追查持有者和分配位置 → 导出证据」串成完整调查流程。解析、引用图、支配关系、保留体积、差分和报告均由 MoonBit 实现。

    MoonHeap 三阶段调查与持有路径

    #已完成的功能

    调查步骤功能
    采集与导入通用 Node 场景采集;分配跟踪;三份快照与 SHA-256 来源记录;真正逐块解析与读取进度
    定位大对象精确 UInt64 ID;名称、类型、大小及可达性筛选;浅体积和保留体积排序
    追查引用入边/出边、最短根路径、多条根路径、循环引用组及成员
    分析持有关系支配树导航、祖先路径、保留体积分布图;共享对象不重复加总
    比较变化聚合比较;同 isolate 对象匹配;自身大小及保留体积变化;两张表独立分页
    清理后验证A 基线、B 增长、C 清理;筛出 A 不存在、B 出现、C 仍存在的对象
    查找来源对象源码位置、分配调用栈;经确认后可回查增长快照的分配记录
    保存与交付对象历史/书签、浏览器工作区保存恢复、全部匹配行的 JSON/CSV/HTML 报告

    报告超限会报错,不静默截断。快照只在本机处理;保存工作区是明确操作,会将原始文件存入当前浏览器。

    **计算边界:**保留体积采用明确的强引用图策略,不等于进程 RSS,也不自动判定泄漏。WeakMap 条件边保守处理,详见语义说明。输入是 V8 快照,不是 MoonBit 原生运行时堆,也不支持 JVM HPROF 或采样型 .heapprofile。

    #三分钟体验

    推荐下载 MoonHeap v0.2.0 的预编译 ZIP。安装 Node.js 22+,解压后进入目录运行:

    node scripts/serve.mjs

    打开 本地界面,点击「加载缓存增长示例」:

    1. 自动加载三份合成快照:图片条目数量为 2 → 18 → 4;A → B 的 PixelBuffer 浅体积增加 512 KiB。
    2. 确认示例来自同一快照序列,切换「清理后调查」,搜索 PhotoEntry,类型选择 object:有 2 个新增后仍存活的条目。
    3. 点击候选,查看清理快照中的根路径,定位 Window → PhotoCache → Array。
    4. 体验支配树、书签、保存恢复,再导出 HTML 报告。来源信息缺失时会明确说明;带分配跟踪的真实示例见下节。

    从源代码开发,需要 MoonBit 工具链(本版验证版本为 moonc v0.10.20+65efd5b93):

    git clone https://github.com/white-bear233/MoonHeap.git cd MoonHeap npm run build npm run demo

    运行界面无需安装 npm 运行时依赖。完整演示步骤见 DEMO.md。

    #采集自己的 Node 场景

    准备一个模块,导出必需的 exercise(context, iteration),以及可选的 setup() 和 cleanup(context)。三个函数均支持 async。setup 返回的 context 会由采集器持有,cleanup 应清除其中希望释放的引用。参考 cache-scenario.mjs。

    node bin/moonheap.mjs capture --scenario examples/cache-scenario.mjs --output-dir work/my-capture --iterations 2 --allocation-traces --json node bin/moonheap.mjs investigate work/my-capture/before.heapsnapshot work/my-capture/growth.heapsnapshot work/my-capture/cleanup.heapsnapshot --manifest work/my-capture/capture.json --exclude-conflicts --query MoonHeapCapturedEntry --kind object --json

    该真实场景对象数是 0 → 16 → 1:应找到唯一残留对象,再查询引用与分配来源。输出目录已有同名采集文件时会拒绝覆盖。默认采集超时 120 秒,可用 --timeout 调整毫秒数。

    将三份文件依次放入网页 A/B/C,确认同一 V8 实例后调查。清理快照偶尔不保留分配跟踪 ID;网页会在确认身份后回查 B,并注明来源。

    采集会暂停被测进程并消耗额外内存;在专用测试环境复现。详见 Node 官方说明。Chrome 可在 Memory 面板保存 Heap snapshot,分配栈取决于采集时是否启用跟踪。

    #命令行与完整报告

    node bin/moonheap.mjs summary examples/growth.heapsnapshot node bin/moonheap.mjs nodes examples/growth.heapsnapshot --kind object --min-retained 32768 --limit 10 node bin/moonheap.mjs node examples/shared.heapsnapshot --id 9007199254740993 node bin/moonheap.mjs refs examples/growth.heapsnapshot --id 7 node bin/moonheap.mjs paths examples/shared.heapsnapshot --id 7 --max-paths 5 --json node bin/moonheap.mjs tree examples/growth.heapsnapshot --id 7 node bin/moonheap.mjs cycles examples/growth.heapsnapshot node bin/moonheap.mjs diff examples/before.heapsnapshot examples/growth.heapsnapshot --same-isolate --status retention_changed --json node bin/moonheap.mjs report examples/growth.heapsnapshot --view nodes --format html --output work/objects.html node bin/moonheap.mjs report examples/before.heapsnapshot examples/growth.heapsnapshot examples/residual.heapsnapshot --view investigate --same-isolate --format csv --output work/survivors.csv

    完整命令:summary、validate、nodes、groups、node、refs、path、paths、source、tree、cycles、cycle、dominated、diff、investigate、report、capture。运行 --help 查看所有参数。

    默认每份文件上限 256 MiB,--max-mib 可设 1–1024;网页也可调整。节点、边、元数据和计算仍有独立预算,不保证上限内的所有复杂图都能在任意机器处理。实际内存大于文件大小。

    身份比较必须来自同一 V8 isolate。--same-isolate 是调用者声明;--manifest capture.json 额外验证采集记录的文件大小与哈希,不能将任意文件名当作来源证明。默认跨进程只做聚合比较。若真实快照中同 ID 的类型/名称矛盾,CLI 默认拒绝身份比较;已确认来源时可用 --exclude-conflicts 排除这些 ID,并在结果中查看排除数量。网页采用排除并提示的方式;聚合统计仍保留所有对象。

    report 收集全部匹配行,忽略分页起点;每个结果列表默认最多 100000 行,可用 --max-rows 调整。文件存在时须明确传 --force 才覆盖。CSV 保护文本公式与精确 ID;HTML 自包含且不运行脚本。详见 API。

    #作为 MoonBit 库

    moon add white-bear233/moonheap@0.2.0

    在调用包的 moon.pkg 中导入 "white-bear233/moonheap" @heap。小文本可用 @heap.parse(input);逐块处理可用:

    fn inspect_chunks(chunks : Array[String]) -> Unit raise @heap.HeapError {
    let decoder = @heap.Decoder::new()
    for chunk in chunks {
    decoder.feed(chunk)
    }
    let snapshot = decoder.finish()
    let analysis = snapshot.graph().analyze()
    for node in analysis.nodes(kind="object", limit=10).items {
    println("\{node.id} \{node.name} shallow=\{node.self_size}")
    }
    }

    UTF-8 字节解码由宿主负责;不要提前把整个文件读入字符串数组来模拟流式读取。三个后端共享核心 API,完整类型声明见 pkg.generated.mbti。运行示例:moon run cmd/main --target js。

    #验证与项目交付

    moon test --target js --deny-warn moon test --target wasm-gc --deny-warn moon info moon fmt npm run build npm test node scripts/benchmark.mjs

    34 个 MoonBit 测试包含 150 个随机图的独立删除节点验证、增量解析、分配来源、多路径、循环、三阶段存活和跨页报告。Node 与浏览器测试验证真实清理及残留场景、65 MiB 逐块输入、错误恢复、保存恢复和完整导出。Linux CI 还验证 native 核心及 Node 22/24,详见 VALIDATION.md。

    快照字节流 → MoonBit Decoder → 引用图 / 支配树 / 来源索引 → 查询 / 比较 / 三阶段调查 / 报告 → 同一 JS 桥接 → Node CLI / Web Worker

    相邻生态项目包括 moon-pprof 的采样/pprof 工具与 moon-hprof 的 JVM HPROF 解析。MoonHeap 提供 V8 对象引用图、支配保留分析及 MoonBit 可复用内核,检索依据与范围区别见申报稿。

    Apache-2.0 原创实现;使用 AI 辅助开发。格式、算法参考及第三方边界见 ACKNOWLEDGEMENTS.md。

    HeapError

    pub(all) suberror HeapError {
    Invalid(String)
    Limit(String)
    NotFound(String)
    Identity(String)
    } derive(
    Debug
    )

    AllocationFrame

    pub(all) struct AllocationFrame {
    function_id : String
    name : String
    script_name : String
    script_id : Int
    line : Int
    column : Int
    }

    Analysis

    type Analysis

    Analysis::compare

    fn Analysis::compare(self : Analysis, after : Analysis, same_isolate? : Bool, exclude_conflicts? : Bool) -> RetentionDiff raise HeapError

    Analysis::dominated

    fn Analysis::dominated(self : Analysis, node : Int, offset? : Int, limit? : Int) -> NodePage raise HeapError

    Analysis::dominator_chain

    fn Analysis::dominator_chain(self : Analysis, node : Int, max_depth? : Int) -> Array[Node] raise HeapError

    Analysis::graph

    fn Analysis::graph(self : Analysis) -> Graph

    Analysis::groups

    fn Analysis::groups(self : Analysis) -> Array[Group]

    Groups sum shallow bytes only. max_retained_bytes is not additive.

    Analysis::immediate_dominator

    fn Analysis::immediate_dominator(self : Analysis, node : Int) -> Int? raise HeapError

    Analysis::iterations

    fn Analysis::iterations(self : Analysis) -> Int

    Analysis::nodes

    fn Analysis::nodes(self : Analysis, order? : String, query? : String, kind? : String, min_retained? : Int64, min_shallow? : Int64, reachability? : String, offset? : Int, limit? : Int) -> NodePage raise HeapError

    Analysis::reachable_count

    fn Analysis::reachable_count(self : Analysis) -> Int

    Analysis::retained_bytes

    fn Analysis::retained_bytes(self : Analysis, node : Int) -> Int64? raise HeapError

    CycleGroup

    pub(all) struct CycleGroup {
    id : Int
    members : Array[Int]
    shallow_bytes : Int64
    }

    Decoder

    type Decoder

    Incremental V8 reader: large numeric arrays never become JSON syntax trees.

    Decoder::characters_read

    fn Decoder::characters_read(self : Decoder) -> Int

    Decoder::feed

    fn Decoder::feed(self : Decoder, chunk : String) -> Unit raise HeapError

    Feed any text chunk. UTF-8 byte decoding belongs to the host.

    Decoder::finish

    fn Decoder::finish(self : Decoder) -> Snapshot raise HeapError

    Decoder::new

    fn Decoder::new(limits? : Limits) -> Decoder raise HeapError

    Edge

    pub(all) struct Edge {
    index : Int
    source : Int
    target : Int
    kind : String
    name : String
    } derive(Eq,
    Debug
    )

    Edge::equal

    fn Edge::equal(Edge, Edge) -> Bool

    Edge::not_equal

    fn Edge::not_equal(x : Edge, y : Edge) -> Bool

    Edge::to_repr

    EdgePage

    pub(all) struct EdgePage {
    items : Array[Edge]
    total : Int
    offset : Int
    }

    Graph

    type Graph

    Graph::analyze

    fn Graph::analyze(self : Graph) -> Analysis raise HeapError

    Iterative reverse-postorder dominators (Cooper-Harvey-Kennedy). No recursion: deeply nested object graphs cannot overflow the call stack.

    Graph::cycles

    fn Graph::cycles(self : Graph) -> Array[CycleGroup]

    Strongly connected groups. A cycle alone is not evidence of a leak.

    Graph::is_retaining

    fn Graph::is_retaining(self : Graph, edge : Int) -> Bool raise HeapError

    Graph::references

    fn Graph::references(self : Graph, node : Int, incoming? : Bool, retaining_only? : Bool, offset? : Int, limit? : Int) -> EdgePage raise HeapError

    Graph::root_distance

    fn Graph::root_distance(self : Graph, node : Int) -> Int? raise HeapError

    Graph::root_path

    fn Graph::root_path(self : Graph, node : Int, max_depth? : Int) -> Array[Edge]? raise HeapError

    Shortest retaining path; None means no root path, an empty path means root.

    Graph::root_paths

    fn Graph::root_paths(self : Graph, node : Int, max_paths? : Int, max_depth? : Int, max_work? : Int) -> PathSearch raise HeapError

    Enumerate bounded simple retaining paths. Search completeness is explicit.

    Graph::snapshot

    fn Graph::snapshot(self : Graph) -> Snapshot

    Graph::weakmap_edge_count

    fn Graph::weakmap_edge_count(self : Graph) -> Int

    Group

    pub(all) struct Group {
    kind : String
    name : String
    count : Int
    shallow_bytes : Int64
    max_retained_bytes : Int64?
    }

    GroupDelta

    pub(all) struct GroupDelta {
    kind : String
    name : String
    before_count : Int
    after_count : Int
    before_bytes : Int64
    after_bytes : Int64
    }

    Inspector

    type Inspector

    Inspector::accept

    fn Inspector::accept(self : Inspector, snapshot : Snapshot) -> Int raise HeapError

    Inspector::close

    fn Inspector::close(self : Inspector, handle : Int) -> Unit

    Inspector::diff

    fn Inspector::diff(self : Inspector, before : Int, after : Int, input : String) ->
    Json
    raise HeapError

    Inspector::export_report

    fn Inspector::export_report(self : Inspector, handle : Int, input : String) -> String raise HeapError

    Inspector::investigate

    fn Inspector::investigate(self : Inspector, before : Int, growth : Int, cleanup : Int, input : String) ->
    Json
    raise HeapError

    Inspector::new

    fn Inspector::new(max_snapshots? : Int) -> Inspector

    Inspector::open

    fn Inspector::open(self : Inspector, input : String, limits? : Limits) -> Int raise HeapError

    Inspector::query

    fn Inspector::query(self : Inspector, handle : Int, input : String) ->
    Json
    raise HeapError

    Inspector::report

    fn Inspector::report(self : Inspector, handle : Int, input : String) ->
    Json
    raise HeapError

    Collect every matching row, fail explicitly when max_rows would be exceeded.

    Investigation

    pub(all) struct Investigation {
    mode : String
    groups : Array[ResidueGroup]
    survivors : Array[Node]
    created : Int?
    released : Int?
    excluded_ids : Array[String]
    }

    Limits

    pub(all) struct Limits {
    max_chars : Int
    max_nodes : Int
    max_edges : Int
    max_strings : Int
    max_work : Int
    } derive(
    Debug
    )

    Limits::default

    fn Limits::default() -> Limits

    Limits::to_repr

    Node

    pub(all) struct Node {
    index : Int
    id : String
    kind : String
    name : String
    self_size : Int64
    edge_start : Int
    edge_count : Int
    } derive(Eq,
    Debug
    )

    Node::equal

    fn Node::equal(Node, Node) -> Bool

    Node::not_equal

    fn Node::not_equal(x : Node, y : Node) -> Bool

    Node::to_repr

    NodePage

    pub(all) struct NodePage {
    items : Array[Node]
    total : Int
    offset : Int
    }

    ObjectChange

    pub(all) struct ObjectChange {
    id : String
    kind : String
    name : String
    status : String
    before_bytes : Int64?
    after_bytes : Int64?
    }

    PathSearch

    pub(all) struct PathSearch {
    paths : Array[Array[Edge]]
    complete : Bool
    examined : Int
    stop_reason : String
    }

    ResidueGroup

    pub(all) struct ResidueGroup {
    kind : String
    name : String
    before_count : Int
    growth_count : Int
    cleanup_count : Int
    before_bytes : Int64
    growth_bytes : Int64
    cleanup_bytes : Int64
    }

    RetentionChange

    pub(all) struct RetentionChange {
    id : String
    kind : String
    name : String
    status : String
    before_bytes : Int64?
    after_bytes : Int64?
    before_retained : Int64?
    after_retained : Int64?
    retained_delta : Int64?
    }

    RetentionDiff

    pub(all) struct RetentionDiff {
    aggregate : SnapshotDiff
    changes : Array[RetentionChange]
    }

    Snapshot

    type Snapshot

    Snapshot::allocation_stack

    fn Snapshot::allocation_stack(self : Snapshot, node : Int) -> Array[AllocationFrame] raise HeapError

    Leaf allocation site first, followed by callers. Frames preserve V8 values.

    Snapshot::allocation_totals

    fn Snapshot::allocation_totals(self : Snapshot, node : Int) -> (Int64, Int64)? raise HeapError

    Allocations recorded at this trace site, not this object's current size.

    Snapshot::edge

    fn Snapshot::edge(self : Snapshot, index : Int) -> Edge raise HeapError

    Snapshot::edge_count

    fn Snapshot::edge_count(self : Snapshot) -> Int

    Snapshot::find_id

    fn Snapshot::find_id(self : Snapshot, id : String) -> Int?

    Snapshot::graph

    fn Snapshot::graph(self : Snapshot) -> Graph

    Build both directions and the root-reachable graph under snapshot-strong-v1.

    Snapshot::node

    fn Snapshot::node(self : Snapshot, index : Int) -> Node raise HeapError

    Snapshot::node_count

    fn Snapshot::node_count(self : Snapshot) -> Int

    Snapshot::root_index

    fn Snapshot::root_index(self : Snapshot) -> Int

    Snapshot::source_location

    fn Snapshot::source_location(self : Snapshot, node : Int) -> SourceLocation? raise HeapError

    Object locations use V8's zero-based line/column coordinates.

    Snapshot::total_shallow_bytes

    fn Snapshot::total_shallow_bytes(self : Snapshot) -> Int64

    SnapshotDiff

    pub(all) struct SnapshotDiff {
    mode : String
    groups : Array[GroupDelta]
    added : Int?
    removed : Int?
    survived : Int?
    changes : Array[ObjectChange]
    excluded_ids : Array[String]
    }

    SourceLocation

    pub(all) struct SourceLocation {
    script_id : Int
    line : Int
    column : Int
    } derive(Eq,
    Debug
    )

    SourceLocation::equal

    SourceLocation::not_equal

    fn SourceLocation::not_equal(x : SourceLocation, y : SourceLocation) -> Bool

    compare

    fn compare(before : Snapshot, after : Snapshot, same_isolate? : Bool, exclude_conflicts? : Bool) -> SnapshotDiff raise HeapError

    Identity is never inferred from IDs. same_isolate is a caller assertion.

    decoder_with_options

    fn decoder_with_options(input : String) -> Decoder raise HeapError

    Configure an incremental reader using the shared versioned request validator.

    failure

    fn failure(error : HeapError) -> String

    investigate

    fn investigate(before : Analysis, growth : Analysis, cleanup : Analysis, same_isolate? : Bool, exclude_conflicts? : Bool) -> Investigation raise HeapError

    Candidates are newly observed after the workload and still present after cleanup. This is evidence of survival, not a proof of unintended retention.

    parse

    fn parse(input : String, limits? : Limits) -> Snapshot raise HeapError

    Read a V8 snapshot. All arrays and metadata are validated before graph analysis.

    success

    fn success(value :
    Json
    ) -> String