Skip to content

feat(desktop): add native memory breakdown to heap captures - #58311

Merged
Hona merged 2 commits into
anomalyco:devfrom
Hona:heap-native-memory
Oct 23, 2026
Merged

Hona merged 2 commits into
anomalyco:devfrom
Hona:heap-native-memory

Conversation

@Hona

@Hona Hona commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

A heap snapshot only holds objects the garbage collectors own, so on adais the app window's 1,186 MB showed up as ~300 MB of heap and 886 MB of nothing. Capture heap snapshots now also records Chromium's per-process, per-allocator memory dump and summarizes it, and the server reports its own memory.

What the folder holds now

opencode-heap-2026-10-23T023312345Z/
  memory.json                  where the memory is, per process, in MiB: start here
  memory-infra.json            Chromium's raw memory dump trace (Perfetto / chrome://tracing)
  heap-24792-…heapsnapshot     server
  window-1-24924.heapsnapshot  app window
  main-22204.heapsnapshot      Electron main
  gpu.json                     GPU and driver
  manifest.json                files, snapshots, metrics before and after; written last
// memory.json (dev build, fresh window)
{
  "unit": "MiB",
  "native": [
    { "name": "GPU Process", "pid": 30340, "totals": { "privateFootprint": 154.3 },
      "allocators": { "malloc": 28.8, "cc": 9.9, "gpu": 9.5 },
      "detail": { "gpu/shared_images": "…", "cc/resource_memory": "…" } },
    { "name": "Renderer", "pid": 24924, "totals": { "privateFootprint": 127.8 },
      "allocators": { "partition_alloc": 37, "malloc": 35.1, "v8": 27.3, "cc": 24.3, "blink_gc": 4.3 },
      "detail": { "cc/tile_memory": 24.3, "partition_alloc/partitions/buffer": "…", "v8/main/heap": "…" } }
  ],
  "server": { "rss": 304.5, "heapTotal": 86, "heapUsed": 99, "external": 50.7, "arrayBuffers": 0.1 },
  "pages": [{ "window": 1, "title": "OpenCode", "contentSize": [1280, 802], "scaleFactor": 1.5,
    "page": { "elements": 773, "images": { "count": 0, "decodedMiB": 0 }, "canvases": { "count": 0, "MiB": 0 },
              "svgUses": 13, "jsHeap": { "usedJSHeapSize": 40316740 } } }]
}

How

sequenceDiagram
  participant M as main (debug/main.ts)
  participant C as Chromium tracing
  participant W as app windows
  participant S as server
  M->>M: getAppMetrics, memoryUsage, v8 heap stats, system memory (before)
  M->>C: startRecording(memory-infra, detailed dump every 1s)
  C-->>M: memory-infra.json after 2.5s
  M->>M: summarize → memory.json
  M->>W: executeJavaScript: DOM size, images, canvases, performance.memory
  par
    M->>S: POST /api/debug/heap-dump?directory=…  → { path, pid, memory }
  and
    M->>W: takeHeapSnapshot, one window at a time
  end
  M->>M: writeHeapSnapshot(main), getGPUInfo, metrics (after), manifest.json
Loading
  • Measurements before snapshots. Writing a snapshot raises the memory of the process it serializes, so metrics, the memory-infra dump and page stats are read first; manifest.json also keeps metrics after.
  • memory-infra. One contentTracing recording covers every Chromium process (browser, renderers, GPU, utility). summarize (debug/memory-infra.ts) takes the latest complete dump: totals and allocators arrive in separate events per dump id, and the browser splits its allocators over two. It reports effective sizes (shared memory counted once), top-level allocators, and sub-allocators of at least 1 MiB up to three levels deep. If another trace is running (DevTools recording a profile), the manifest records the error and the snapshots still run.
  • Server memory. HeapSnapshot.write reads process.memoryUsage() just before writing and the endpoint returns it as memory; the SIGUSR1 log line includes rss and heapUsed. The desktop records it per server snapshot; servers released before this leave it out.
  • Page stats per window: element count, images and their decoded size (4 bytes per pixel), canvases, <use> count, viewport, device pixel ratio, zoom, performance.memory.
  • Also recorded: main's v8.getHeapStatistics() and heap spaces, process.getSystemMemoryInfo(), app.getGPUInfo("basic"), app uptime.

Follow-up to #58259. Docs (troubleshooting.mdx) and the opencode-dev skill describe the new files.


Added by the opencode bot (AI-generated).

Related: #58268

Public API: adds required memory to the POST /api/debug/heap-dump response; desktop manifest.json moves metrics/mainMemory under before/after (additive for the API; manifest readers must update)

Copilot AI balanced review requested due to automatic review settings October 23, 2026 02:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@Hona
Hona force-pushed the heap-native-memory branch from 4df7ad9 to 023d32b Compare October 23, 2026 02:36
@Hona
Hona requested a review from Brendonovich as a code owner October 23, 2026 02:36
@Hona
Hona changed the base branch from beta to dev October 23, 2026 02:36

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks sound overall. Tests and type checks pass (tsc for gui-extensions, util, protocol, server and client). I couldn't run the Electron part here (memory-infra recording, page stats, memory.json/gpu.json), so the trace summary is only checked against the hand-made trace in the test.

Public API impact:

  • POST /api/debug/heap-dump: the response gains a memory object (rss, heapTotal, heapUsed, external, arrayBuffers, in bytes). It is required in the OpenAPI spec and in DebugHeapDumpOutput in the client. This is additive and compatible, but typed callers talking to an older server will get undefined despite the non-optional type. The desktop already handles this.
  • Desktop capture folder: adds memory.json, memory-infra.json and gpu.json. In manifest.json, the top-level metrics and mainMemory move to before.{metrics,mainMemory} and after.{metrics,mainMemory}, and files and app.uptimeSeconds are new. Any script reading manifest.json.metrics needs to update.

Two concerns inline: the new page-stats step can hang the whole capture on an unresponsive window, and the memory-infra summary may pick a dump that not every process has reported into yet.

Related: #58268


const native = await recordNative(directory)
const windows = ctx.windows.list()
const pages = await Promise.all(windows.map((window) => describeWindow(window)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

describeWindow runs before the server and window snapshots start, and executeJavaScript never settles if a renderer is hung or busy, which is likely in the leaking app this tool is for. A single stuck window would then block the whole capture, including the server snapshot, which used to run alongside the windows. Could the page stats get a timeout (e.g. race with a few seconds and record an error), so the snapshots still run?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0cbe78c. Page stats now race a 3 second timeout. A window that does not answer gets { "error": "The window did not answer within 3 seconds." } in memory.json, and the snapshots run as before.

// events (the browser in two). The latest dump with allocators is the latest complete picture.
const dumps = events.flatMap((event) =>
event.ph === "v" && event.args?.dumps ? [{ ...event, dumps: event.args.dumps }] : [],
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

latest is the last dump id that has any allocators event, so if stopRecording cuts into a global dump that only some processes have reported (detailed dumps get slower under memory pressure), current holds just those processes. The rest silently drop out of memory.json, and the browser may show only one of its two allocator events. Picking the latest dump per pid, or the latest id that every dumping process has both totals and allocators for, would match the "latest complete dump" described in the PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0cbe78c. summarize now uses the latest dump id that every dumping process reported both totals and allocators for, and only falls back to the latest id with any allocators when no dump is complete. The test adds a cut-off final dump (only the renderer reported) and fails without the fix.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier points are fixed. Page stats now give up after 3 seconds, so a stuck window no longer blocks the snapshots. summarize now prefers the latest dump that every process sent both totals and allocators for, and the new test case covers it. The memory-infra and server-heap tests and tsc in packages/gui-extensions pass. I couldn't run the Electron capture itself here. This push doesn't change the public API, and I have nothing new to raise.

@Hona
Hona merged commit 5f79a2a into anomalyco:dev Oct 23, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants