Skip to content

Video view leaks memory: createObjectURL() per frame, never revoked (~2700 blobs / 200 MB in 8 min; OOMed a 32 GB desktop overnight) #618

Description

@mlugo-apx

Summary

The device video view calls URL.createObjectURL() once per frame and never calls URL.revokeObjectURL(). Every frame is permanently registered in WebKit's blob registry, so the app grows at roughly the video bitrate for as long as the view stays open — about 25–60 MB/min, without bound.

Left open overnight on a 32 GB desktop this reached 18.1 GB and locked the machine (details below). Measured directly: 2,737 blob URLs created, 0 revoked in 8.4 minutes.

Reproduced on 2.3.4 (dashboard 2.3.18) and 2.3.5 (dashboard 2.3.25). Slicing alone never leaks — with no device connection the app sits flat at ~450 MB. Growth requires the video stream, and stops the moment it is closed.

Workaround for users: don't leave the device video view open unattended, especially overnight.

Root cause

URL.createObjectURL() registers a blob in WebKit's blob registry, which lives in the network process, keyed by the URL string. That is a native strong reference — garbage collection cannot reclaim it, and only URL.revokeObjectURL() releases it. Creating one per frame without revoking leaks at frame_rate × frame_size indefinitely.

Measurement

I hooked both blob APIs in the dashboard pages through the WebKit remote inspector (Runtime.evaluate into the flutter_web targets) and sampled the app cgroup alongside the counters:

(function(){
  var c=0, r=0, live=0, bytes=0;
  var oc=URL.createObjectURL, orv=URL.revokeObjectURL;
  URL.createObjectURL=function(o){ c++; live++;
    try{ if(o&&typeof o.size==='number') bytes+=o.size; }catch(e){}
    return oc.apply(URL,arguments); };
  URL.revokeObjectURL=function(u){ r++; live--; return orv.apply(URL,arguments); };
  window.__blobProbe=function(){ return JSON.stringify({created:c,revoked:r,live:live,bytes:bytes}); };
})()

2.3.5 flatpak, device connected, video view open, no print running, app otherwise idle:

Elapsed App memory Blobs created Blobs revoked Live blobs Blob payload
+63 s +53 MB 608 0 608 45.3 MB
+189 s +110 MB 1,038 0 1,038 77.3 MB
+252 s +146 MB 1,456 0 1,456 108.4 MB
+315 s +178 MB 1,883 0 1,883 140.2 MB
+441 s +254 MB 2,309 0 2,309 171.9 MB
+504 s +277 MB 2,737 0 2,737 203.7 MB

~5–10 blobs/s (the frame rate) at ~74 KB each. Retained blob payload accounts for ~74% of total app growth; the remainder is registry and decoded-frame overhead. (Two intermediate rows showed an unchanged counter where my sampler dropped a reply; the trend and the zero revocation count are unaffected.)

Supporting evidence that rules out other causes

  • Not response-body caching. Over a 10-minute window the dashboard received 1.7 MB over HTTP while the app grew 602 MB — a ~350× mismatch. A cache-model change would not fix this.
  • Not collectable JS garbage. Forcing Heap.gc on all four dashboard targets freed 24 MB of 4,816 MB (0.5%), and memory stayed flat for 60 s afterwards. The retention is native.
  • Strictly tied to the stream. With the video view closed, blob creation is 0/min and memory is flat (verified over ~10 minutes idle). Reopening it resumed both immediately.
  • Explains the cross-process symptom. Growth shows up in the network process even though rendering happens in the web process, because the blob registry lives in the network process.

Suggested fix

Revoke each object URL once the frame has been handed to the image element:

img.onload = () => URL.revokeObjectURL(img.src);

Equivalent approaches also work: reuse a single object URL, or avoid them entirely via createImageBitmap() / drawing to a canvas.

This lives in the precompiled Flutter bundle (resources/web/flutter_web/main.dart.js), so I could not patch and verify it locally. The probe above will confirm a fix in seconds though: after the change, revoked should track created and live should stay bounded. Happy to re-run it against any test build.

Real-world impact

Snapmaker Orca was left connected overnight with the video view open on 2.3.4. By morning the machine was unusable and required a hard power-off. From the systemd-oomd candidate dump:

  • The app's cgroup had reached 18.1 GB after ~20 hours.
  • oomd killed Chrome (3.4 GB, 62 processes) instead — it selects on memory pressure and reclaim activity, not absolute size, so the actual offender survived.
  • The system then swap-thrashed for ~3.5 hours (input lag, D-Bus timeouts) until power-off.

On 2.3.5 the same pattern is slower but unbounded: a later session reached a 19.4 GB footprint (7.7 GB resident + 11.7 GB swapped) over ~7 hours, consuming half the machine's 24 GB swap file.

Environment

  • Snapmaker Orca 2.3.4 and 2.3.5 Linux flatpak, x86_64, from the release bundles (Snapmaker_Orca-Linux-flatpak_V2.3.4_x86_64.flatpak, ..._V2.3.5_x86_64.flatpak)
  • Bundled dashboard versions from files/share/Snapmaker_Orca/web/flutter_web/version.json: 2.3.18 (in 2.3.4), 2.3.25 (in 2.3.5)
  • Ubuntu 24.04.4, kernel 6.17.0-40-generic, 32 GB RAM, 24 GB swap
  • Printer: Snapmaker U1 over LAN (clean network; no OS-level disconnects logged)

Method: app cgroup sampled every 60 s (memory.current + memory.swap.current + per-process RSS from cgroup.procs). Note that RSS alone understates the leak once swapping begins.

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions