<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Murlet - Blog</title>
    <subtitle>Murlet records your security cameras and understands what&#x27;s happening, entirely on your Mac. Free, no cloud, no accounts. Only you can see your footage.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://murlet.com/blog/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://murlet.com/blog/"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-09-14T00:00:00+00:00</updated>
    <id>https://murlet.com/blog/atom.xml</id>
    <entry xml:lang="en">
        <title>Murlet now finds your cameras via ONVIF</title>
        <published>2026-09-14T00:00:00+00:00</published>
        <updated>2026-09-14T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://murlet.com/blog/finding-your-cameras/"/>
        <id>https://murlet.com/blog/finding-your-cameras/</id>
        
        <content type="html" xml:base="https://murlet.com/blog/finding-your-cameras/">&lt;p&gt;Murlet, my macOS network video recorder (NVR), used to require you to supply a
cryptic RTSP string for each camera:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;rtsp:&#x2F;&#x2F;user:pass@192.168.0.125:554&#x2F;cam&#x2F;realmonitor?channel=1&amp;amp;subtype=0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;How do you even construct one of these?  Well, you need three pieces of
information.  Firstly, you find your camera&#x27;s IP address, likely by logging into
your router and browsing the list of devices, looking for something
camera-shaped.  Secondly, you probably Google for your camera model&#x27;s RTSP URL.
Finally, you try to find the post-it note where you wrote down your camera&#x27;s
password six months ago.&lt;&#x2F;p&gt;
&lt;p&gt;Good news and bad news: I can&#x27;t help you find your password, but &lt;em&gt;everything
else&lt;&#x2F;em&gt; is now automated.  During onboarding (and later, from camera settings) we
show you a list of discovered cameras:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;blog&#x2F;finding-your-cameras&#x2F;discovered-cameras.png&quot; alt=&quot;Murlet&amp;#39;s discovery panel, headed &amp;quot;Looking for cameras on your network...&amp;quot;, listing five cameras found on the network with their model and IP address; the first is marked Online and the other four each have an Add button&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Clicking any of the &quot;Add&quot; buttons brings up the password dialog.  Once you&#x27;ve
added a camera, you can see its status (e.g. &quot;Online&quot;).  We even attempt to
check whether your password is valid before adding the camera.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-onvif-discovery-works&quot;&gt;How ONVIF discovery works&lt;&#x2F;h2&gt;
&lt;p&gt;Behind the scenes, this is all driven by ONVIF.  Which stands for Open Network
Video Interface Forum, obviously.  We blast out a
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;specs.xmlsoap.org&#x2F;ws&#x2F;2005&#x2F;04&#x2F;discovery&#x2F;ws-discovery.pdf&quot;&gt;WS-Discovery&lt;&#x2F;a&gt;
&lt;code&gt;Probe&lt;&#x2F;code&gt; (SOAP, remember it?) to the multicast group at &lt;code&gt;239.255.255.250:3702&lt;&#x2F;code&gt;,
and wait for any cameras to respond.  This provides the list of cameras that you
see in the screenshot above.  Then, once you&#x27;ve added a camera with a password,
we ask the camera for its RTSP address via &lt;code&gt;GetStreamUri&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Easy, right?  Not exactly.  IP cameras are a &lt;em&gt;swamp&lt;&#x2F;em&gt; of partially implemented,
non-compliant ONVIF stacks.  And people connect to them in creative ways.  Most
of the quirks below tripped me up on my own cameras (and I flag the speculative
fixes).&lt;&#x2F;p&gt;
&lt;h3 id=&quot;probing-every-network-interface&quot;&gt;Probing every network interface&lt;&#x2F;h3&gt;
&lt;p&gt;If we don&#x27;t specify an interface when sending the multicast WS-Discovery
message, it goes out on whichever one the OS picks as the default.  Most of the
time this finds your cameras, but if you have multiple interfaces (e.g. Wi-Fi
for regular traffic, and an isolated wired network for cameras) then it may
mysteriously show no devices.&lt;&#x2F;p&gt;
&lt;p&gt;We address this problem by enumerating the machine&#x27;s interfaces via
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;man.freebsd.org&#x2F;cgi&#x2F;man.cgi?query=getifaddrs&amp;amp;sektion=3&quot;&gt;getifaddrs&lt;&#x2F;a&gt;,
and sending discovery messages out on each.  We intentionally skip
point-to-point interfaces to avoid adding cameras that are only reachable when a
VPN is up.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;anchoring-to-the-camera-s-clock&quot;&gt;Anchoring to the camera&#x27;s clock&lt;&#x2F;h3&gt;
&lt;p&gt;If you&#x27;ve ever had a camera reject credentials you &lt;em&gt;know&lt;&#x2F;em&gt; are correct, returning
an ONVIF &lt;code&gt;ter:NotAuthorized&lt;&#x2F;code&gt; or a WS-Security &lt;code&gt;wsse:FailedAuthentication&lt;&#x2F;code&gt; error,
a clock mismatch might be to blame.  Every authenticated request we send carries
a
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;docs.oasis-open.org&#x2F;wss&#x2F;2004&#x2F;01&#x2F;oasis-200401-wss-username-token-profile-1.0.pdf&quot;&gt;WS-Security digest&lt;&#x2F;a&gt;,
which is computed as&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;base64(sha1(nonce + created + password))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;where &lt;code&gt;nonce&lt;&#x2F;code&gt; is the usual number-used-once, and &lt;code&gt;created&lt;&#x2F;code&gt; is a timestamp.  Both
are also sent in the clear so that the camera can recompute the digest.&lt;&#x2F;p&gt;
&lt;p&gt;The camera will reject the message if the timestamp is too far from what it
considers to be the current time, &lt;em&gt;which could be absolutely anything&lt;&#x2F;em&gt; (two of
my cameras currently believe it&#x27;s 2018... I don&#x27;t have the heart to tell them).
So we first send a &lt;code&gt;GetSystemDateAndTime&lt;&#x2F;code&gt; message to learn what the camera
thinks the current time is; this is one of the few calls that ONVIF requires to
work &lt;em&gt;without&lt;&#x2F;em&gt; authentication.  We then anchor all subsequent timestamps to that
value by storing the clock delta.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;cameras-that-lie-about-their-address&quot;&gt;Cameras that lie about their address&lt;&#x2F;h3&gt;
&lt;p&gt;When a camera answers our &lt;code&gt;Probe&lt;&#x2F;code&gt;, its &lt;code&gt;ProbeMatch&lt;&#x2F;code&gt; contains an &lt;code&gt;XAddrs&lt;&#x2F;code&gt; field:
a whitespace-separated list of URLs for its ONVIF device service, e.g.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;http:&#x2F;&#x2F;192.168.0.125&#x2F;onvif&#x2F;device_service&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;http:&#x2F;&#x2F;[fe80::2e0:4cff:fe12:3456]&#x2F;onvif&#x2F;device_service&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;We need to connect to one of these to ask the camera anything further.  The
problem is that &lt;em&gt;we&#x27;re trusting the camera to know its own address&lt;&#x2F;em&gt;.  It might
be stale if the camera has recently acquired a new address from DHCP, or maybe
it&#x27;s the factory-baked default if the camera is truly demonic.  (All of
mine tell the truth, for what n=7 is worth.)&lt;&#x2F;p&gt;
&lt;p&gt;The fix is to... not trust the camera.  We pick the first &lt;code&gt;http&lt;&#x2F;code&gt; URL (skipping
&lt;code&gt;https&lt;&#x2F;code&gt;, which we don&#x27;t speak), and substitute the IPv4 address that the
datagram arrived from, keeping the port and path.  My assumption is that if the
camera is reachable at all, it&#x27;s at the address it just answered from.&lt;&#x2F;p&gt;
&lt;p&gt;This would of course break a camera that is only reachable at the address it
advertises (e.g. behind a proxy).  My hope is that these cases are sufficiently
rare that the rewriting approach leads to fewer connection failures overall.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;not-locking-yourself-out&quot;&gt;Not locking yourself out&lt;&#x2F;h3&gt;
&lt;p&gt;Some cameras lock you out if you enter the wrong credentials too many times.
For a product that records camera streams, getting you locked out of your own
cameras because you typo&#x27;d a password is Not Good.  Concretely: you typo a
password, our reconnect loop retries it every few seconds, and then the camera
starts denying all requests.  Recovery runs the gamut of sysadmin tedium, from
&quot;wait for a bit&quot; to &quot;power cycle the camera&quot; to &quot;perform a factory reset&quot;.&lt;&#x2F;p&gt;
&lt;p&gt;So when authentication fails, Murlet won&#x27;t retry that password in the background
for an hour:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;http:&#x2F;&#x2F;192.168.0.125&#x2F;onvif&#x2F;device_service rejected these credentials less than 3600s ago; not retrying yet&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;An hour is long enough not to trip a lockout and short enough that a camera with
flaky auth comes back the same day.  You can retry as often as you like
yourself: the backoff only applies to Murlet&#x27;s own retries.  We record when each
credential was last rejected, so a fresh password is tried immediately.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;picking-a-media-profile&quot;&gt;Picking a media profile&lt;&#x2F;h3&gt;
&lt;p&gt;ONVIF supports multiple &lt;em&gt;media profiles&lt;&#x2F;em&gt;, listed by &lt;code&gt;GetProfiles&lt;&#x2F;code&gt;.  These
describe the different codec and resolution options available.  We heuristically
pick the highest resolution stream which we can decode.  Why heuristics?
Firstly, cameras may use different names for the same codec e.g. &quot;H.264&quot; vs
&quot;H264&quot; vs &quot;h264&quot;.  Secondly, some may not report an encoding at all.  We keep
them, assuming a camera that can&#x27;t name its codec is probably speaking H.264.
Finally, some may not report any dimensions, so again we just keep the profile,
but rank it below any profiles that report dimensions.  None of mine do any of
this, so no camera has exercised these fallbacks.  What could possibly go wrong?&lt;&#x2F;p&gt;
&lt;h3 id=&quot;if-your-camera-doesn-t-speak-onvif&quot;&gt;If your camera doesn&#x27;t speak ONVIF&lt;&#x2F;h3&gt;
&lt;p&gt;Not all cameras support ONVIF (Hi &lt;a href=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;cameras&#x2F;wyze-cam-v3&#x2F;&quot;&gt;Wyze&lt;&#x2F;a&gt;).  Some do,
but only once you&#x27;ve dug through their settings pages to enable it (Hi
&lt;a href=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;cameras&#x2F;reolink-rlc-510a&#x2F;&quot;&gt;Reolink&lt;&#x2F;a&gt;).  Others need their own user account,
distinct from the login that you use in the web interface or mobile app (Hi
&lt;a href=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;cameras&#x2F;tp-link-tapo-c200&#x2F;&quot;&gt;Tapo&lt;&#x2F;a&gt;, and Reolink again).  So as a fallback we
still support manually entering an RTSP URL.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-didn-t-build&quot;&gt;What I didn&#x27;t build&lt;&#x2F;h2&gt;
&lt;p&gt;Building the perfect discovery system would take forever.  Here&#x27;s what didn&#x27;t
make the cut:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cameras that get a new address&lt;&#x2F;strong&gt;: It&#x27;s not uncommon for a camera to be
assigned a new IP address by DHCP.  When this happens you have to manually
reconfigure your camera in Murlet.  &lt;em&gt;In theory&lt;&#x2F;em&gt; we can automatically detect
the moved camera using ONVIF Endpoint Reference (EPR) addresses, which are
meant to be stable, globally-unique identifiers.  And, of course, some cameras
reuse them across batches, or reset on reboot, so heuristics are the order of
the day.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Media profile selection&lt;&#x2F;strong&gt;: I think there&#x27;s value in exposing the media
profile choice, instead of always automatically deciding it for you.  Higher
resolution streams consume more resources, and they might not always be
needed.  However, I don&#x27;t want to &lt;em&gt;force&lt;&#x2F;em&gt; selection of the media profile if
our heuristics usually do a good-enough job.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Manual subnet scanning&lt;&#x2F;strong&gt;: Right now we do periodic multicast &quot;pings&quot; while
discovering cameras.  This is akin to standing in the street and shouting &quot;is
anyone there?&quot;.  One step up from this is to perform a TCP connect sweep of
all connected networks.  This is more aggressive, and more like knocking on
front doors.  It might also trigger corporate Intrusion Detection Systems.  If
Murlet were to support this behavior, it would have to be user-initiated.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Default passwords&lt;&#x2F;strong&gt;: Many cameras ship with default passwords.  We don&#x27;t
surface these in the UI, instead just presenting a dialog with empty user and
password fields.  As an alternative, I can imagine shipping a database of
default credentials, and suggesting any that match the camera&#x27;s manufacturer
or hardware hints in the WS-Discovery replies.  I wouldn&#x27;t want to
&lt;em&gt;automatically&lt;&#x2F;em&gt; try credentials because (i) this risks camera lockout, and
(ii) trying passwords until one works is no longer in the realm of &quot;camera
setup&quot;.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;does-it-find-your-cameras&quot;&gt;Does it find your cameras?&lt;&#x2F;h2&gt;
&lt;p&gt;I&#x27;ve tested this on the &lt;a href=&quot;..&#x2F;..&#x2F;cameras&#x2F;&quot;&gt;seven cameras&lt;&#x2F;a&gt; I own, but I&#x27;m
undoubtedly missing edge cases.  If you&#x27;re interested in helping make Murlet
more robust,
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;murletapp&#x2F;murlet-releases&#x2F;releases&#x2F;latest&#x2F;download&#x2F;murlet.dmg&quot;&gt;download the latest release&lt;&#x2F;a&gt;
and &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;murletapp&#x2F;murlet-releases&#x2F;issues&quot;&gt;let me know&lt;&#x2F;a&gt; if any of
your cameras don&#x27;t show up.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Proving my Rust NVR doesn&#x27;t leak memory (it did)</title>
        <published>2026-08-28T00:00:00+00:00</published>
        <updated>2026-08-28T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://murlet.com/blog/hunting-rust-memory-leaks/"/>
        <id>https://murlet.com/blog/hunting-rust-memory-leaks/</id>
        
        <content type="html" xml:base="https://murlet.com/blog/hunting-rust-memory-leaks/">&lt;p&gt;Murlet, my macOS network video recorder (NVR), has to be &lt;em&gt;utterly reliable&lt;&#x2F;em&gt;: an
NVR typically runs unattended for weeks at a time, and someone only interacts
with it when they need to review an event.  If in that moment they discover the
NVR crashed three weeks ago... yep, that would be a deal-breaker for me too.&lt;&#x2F;p&gt;
&lt;p&gt;One pernicious source of in-the-field failures is data structures that slowly
grow without bound, especially those in third-party dependencies.  These are
invisible to tools such as the macOS &lt;code&gt;leaks&lt;&#x2F;code&gt; command and the Clang
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;clang.llvm.org&#x2F;docs&#x2F;LeakSanitizer.html&quot;&gt;LeakSanitizer&lt;&#x2F;a&gt; because the data
is still reachable.  And when this growth is sufficiently slow, it can be
invisible during end-to-end testing, only causing memory pressure after weeks or
even months of uptime.  (To keep things simple, I&#x27;m going to call this kind of
unbounded growth a &lt;em&gt;memory leak&lt;&#x2F;em&gt;.)&lt;&#x2F;p&gt;
&lt;p&gt;I set out to prove to myself that Murlet wasn&#x27;t leaking memory, and
discovered (horror of horrors!) that it actually &lt;em&gt;was&lt;&#x2F;em&gt;.
This blog post is the story of how I found and fixed it.  We&#x27;ll cover:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;How I separated the Rust and Electron heaps so that the macOS &lt;code&gt;vmmap&lt;&#x2F;code&gt; tool
reports their sizes independently.&lt;&#x2F;li&gt;
&lt;li&gt;How I taught Murlet to gather and log its own versions of the memory metrics
that Activity Monitor and &lt;code&gt;vmmap&lt;&#x2F;code&gt; show, so that I can graph them over time.&lt;&#x2F;li&gt;
&lt;li&gt;How I used the &lt;code&gt;heap&lt;&#x2F;code&gt; and &lt;code&gt;malloc_history&lt;&#x2F;code&gt; tools to trace the leak to wgpu.&lt;&#x2F;li&gt;
&lt;li&gt;How I fixed the underlying issue. (And to be completely fair to wgpu, the leak
only happens when repeatedly hitting an error path, and it&#x27;s already gone from
wgpu&#x27;s trunk.)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;There&#x27;s a fair amount of ground to cover here, so I&#x27;m going to gloss over some
of the implementation details.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;separating-the-rust-and-electron-heaps&quot;&gt;Separating the Rust and Electron heaps&lt;&#x2F;h2&gt;
&lt;p&gt;As discussed in my &lt;a href=&quot;..&#x2F;rendering-wgpu-under-electron&quot;&gt;previous blog post&lt;&#x2F;a&gt;,
Murlet comprises an Electron frontend and a Rust backend, implemented as a
native Electron plugin. Electron&#x27;s Chromium core uses a
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;chromium.googlesource.com&#x2F;chromium&#x2F;src&#x2F;+&#x2F;5c9fecb6a297de9e7a53c284e187ef0781489183&#x2F;base&#x2F;allocator&#x2F;README.md#unified-allocator-shim&quot;&gt;shim&lt;&#x2F;a&gt;
to divert all memory allocations to its own
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;chromium.googlesource.com&#x2F;chromium&#x2F;src&#x2F;+&#x2F;5c9fecb6a297de9e7a53c284e187ef0781489183&#x2F;base&#x2F;allocator&#x2F;partition_allocator&#x2F;PartitionAlloc.md&quot;&gt;PartitionAlloc&lt;&#x2F;a&gt;
allocator.  Murlet&#x27;s Rust backend runs in the same address space as the Electron
frontend, and therefore by default all Rust allocations &lt;em&gt;also&lt;&#x2F;em&gt; use
PartitionAlloc.&lt;&#x2F;p&gt;
&lt;p&gt;This makes it hard to disentangle Rust and Electron memory footprints: the
PartitionAlloc shim routes every allocation into a single shared partition,
losing each allocation&#x27;s provenance.  I &lt;em&gt;could&lt;&#x2F;em&gt; have recovered it using
Chromium&#x27;s
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;chromium.googlesource.com&#x2F;chromium&#x2F;src&#x2F;+&#x2F;5c9fecb6a297de9e7a53c284e187ef0781489183&#x2F;docs&#x2F;memory-infra&#x2F;heap_profiler.md&quot;&gt;heap profiler&lt;&#x2F;a&gt;,
which logs call stacks of sampled allocations.  But that tooling is built for
one-off investigations, where you already suspect a problem. I wanted
metrics I could leave on in the field, and graph as part of a pre-release soak
test.&lt;&#x2F;p&gt;
&lt;p&gt;What to do about this?  First a brief detour into
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;apple-oss-distributions&#x2F;libmalloc&quot;&gt;libmalloc&lt;&#x2F;a&gt;, Apple&#x27;s
system allocator.  This has the built-in concept of &lt;em&gt;zones&lt;&#x2F;em&gt;: buckets
which applications can direct specific allocations into. Significantly, zones
are surfaced across the standard macOS debugging tooling. &lt;code&gt;vmmap&lt;&#x2F;code&gt; reports each
one separately, and &lt;code&gt;heap&lt;&#x2F;code&gt; and &lt;code&gt;malloc_history&lt;&#x2F;code&gt; can inspect their contents.&lt;&#x2F;p&gt;
&lt;p&gt;The zone-aware API is straightforward: create and name a zone
(&lt;code&gt;malloc_create_zone&lt;&#x2F;code&gt;, &lt;code&gt;malloc_set_zone_name&lt;&#x2F;code&gt;), then allocate through
zone-flavored versions of &lt;code&gt;malloc&lt;&#x2F;code&gt;, &lt;code&gt;calloc&lt;&#x2F;code&gt;, &lt;code&gt;realloc&lt;&#x2F;code&gt; and &lt;code&gt;free&lt;&#x2F;code&gt;.  I replaced
Rust&#x27;s global allocator with a thin layer over this API.  It creates a dedicated
&lt;code&gt;murlet_rust&lt;&#x2F;code&gt; zone and routes every Rust allocation into it.  Since Chromium&#x27;s
shim
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;chromium.googlesource.com&#x2F;chromium&#x2F;src&#x2F;+&#x2F;5c9fecb6a297de9e7a53c284e187ef0781489183&#x2F;base&#x2F;allocator&#x2F;partition_allocator&#x2F;src&#x2F;partition_alloc&#x2F;shim&#x2F;allocator_shim_override_apple_default_zone.h#344&quot;&gt;only intercepts the default zone&lt;&#x2F;a&gt;,
these allocations bypass PartitionAlloc entirely. It&#x27;s not magical: about 150
lines of Rust to implement &lt;code&gt;std::alloc::GlobalAlloc&lt;&#x2F;code&gt;, and a similar amount of
test code.&lt;&#x2F;p&gt;
&lt;p&gt;Three asides:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Replacing the global allocator is a smaller change than it might sound: in an
ordinary macOS process, Rust&#x27;s default &lt;code&gt;System&lt;&#x2F;code&gt; allocator is already libmalloc
(&lt;code&gt;malloc&lt;&#x2F;code&gt; targets the default zone), so we&#x27;re just pointing the allocations at
a named zone instead.&lt;&#x2F;li&gt;
&lt;li&gt;Rust&#x27;s allocator API lets callers request any power-of-two alignment, but
&lt;code&gt;malloc_zone_malloc&lt;&#x2F;code&gt;, like plain &lt;code&gt;malloc&lt;&#x2F;code&gt;, only guarantees 16 bytes.  For
larger alignments there&#x27;s &lt;code&gt;malloc_zone_memalign&lt;&#x2F;code&gt;, but no aligned &lt;code&gt;calloc&lt;&#x2F;code&gt; or
&lt;code&gt;realloc&lt;&#x2F;code&gt;, so I fill those gaps by hand, zeroing and reallocating by copy.
Rust&#x27;s own
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;rust-lang&#x2F;rust&#x2F;blob&#x2F;1.98.0&#x2F;library&#x2F;std&#x2F;src&#x2F;sys&#x2F;alloc&#x2F;unix.rs&quot;&gt;&lt;code&gt;System&lt;&#x2F;code&gt; allocator&lt;&#x2F;a&gt;
plays the same tricks.&lt;&#x2F;li&gt;
&lt;li&gt;Building on libmalloc (as opposed to using a bundled allocator such as
jemalloc or mimalloc) also gives me sanitizer coverage:
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;clang.llvm.org&#x2F;docs&#x2F;AddressSanitizer.html&quot;&gt;AddressSanitizer&lt;&#x2F;a&gt;
interposes the &lt;code&gt;malloc_zone_*&lt;&#x2F;code&gt; functions, so when I periodically run Murlet&#x27;s
tests under ASan, it still checks every Rust allocation.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;With the zone allocator in place, pointing &lt;code&gt;vmmap&lt;&#x2F;code&gt; at a running Murlet
instance shows the Rust heap as its own row.  For leak hunting, we care about
the &quot;BYTES ALLOCATED&quot; figure: these are the bytes that Rust is actually
holding (its live bytes).
&lt;code&gt;murlet_live555&lt;&#x2F;code&gt; is a separate zone that I added to track allocations from
&lt;a rel=&quot;external&quot; href=&quot;http:&#x2F;&#x2F;www.live555.com&#x2F;liveMedia&#x2F;&quot;&gt;live555&lt;&#x2F;a&gt;, the C++ library that Murlet uses
for RTSP.  Fragmentation (&quot;% FRAG&quot;) is a story for another time.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ PID=`pgrep -f &#x2F;Applications&#x2F;Murlet.app&#x2F;Contents&#x2F;MacOS&#x2F;Murlet`&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ vmmap -summary $PID&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                          VIRTUAL   RESIDENT      DIRTY    SWAPPED ALLOCATION      BYTES DIRTY+SWAP          REGION&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;MALLOC ZONE                                  SIZE       SIZE       SIZE       SIZE      COUNT  ALLOCATED  FRAG SIZE  % FRAG   COUNT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;===========                               =======  =========  =========  =========  =========  =========  =========  ======  ======&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[...]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;murlet_rust_0x109f24000                     38.1M      36.1M      36.1M        96K      48294      18.3M      17.9M     50%      24&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;murlet_live555_0x13b10c000                  15.0M      3376K      3376K         0K         15      15.0M         0K      0%       5&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;What about Electron&#x27;s C++ heap, which also used to contain Rust&#x27;s allocations?
Chromium tags PartitionAlloc&#x27;s memory regions with a dedicated constant, defined
in its
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;chromium.googlesource.com&#x2F;chromium&#x2F;src&#x2F;+&#x2F;5c9fecb6a297de9e7a53c284e187ef0781489183&#x2F;base&#x2F;allocator&#x2F;partition_allocator&#x2F;src&#x2F;partition_alloc&#x2F;page_allocator.h#92&quot;&gt;PageTag enum&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;cpp&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #FF79C6;&quot;&gt;enum class&lt;&#x2F;span&gt;&lt;span style=&quot;color: #8BE9FD;font-style: italic;&quot;&gt; PageTag&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6272A4;&quot;&gt;  &#x2F;&#x2F; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  kPartitionAlloc&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FF79C6;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #BD93F9;&quot;&gt; 253&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6272A4;&quot;&gt;  &#x2F;&#x2F; PartitionAlloc, no matter the partition.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6272A4;&quot;&gt;  &#x2F;&#x2F; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;};&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And sure enough, there it is as &quot;Memory Tag 253&quot; further up in the same &lt;code&gt;vmmap&lt;&#x2F;code&gt;
report.  The 48.0G virtual size is PartitionAlloc&#x27;s up-front address-space
reservation; its actual footprint is the sum of the &quot;DIRTY SIZE&quot; and &quot;SWAPPED
SIZE&quot; columns (pages it has written to).&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                VIRTUAL RESIDENT    DIRTY  SWAPPED VOLATILE   NONVOL    EMPTY   REGION&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;REGION TYPE                        SIZE     SIZE     SIZE     SIZE     SIZE     SIZE     SIZE    COUNT (non-coalesced)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;===========                     ======= ========    =====  ======= ========   ======    =====  =======&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[...]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Memory Tag 253                    48.0G   327.4M   210.4M    2592K       0K       0K       0K     5333&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This single anonymous row is all the visibility macOS tooling has into
PartitionAlloc.  As we&#x27;ll see in the rest of this post, the Rust heap&#x27;s zone
is debuggable using standard tools such as &lt;code&gt;heap&lt;&#x2F;code&gt; and &lt;code&gt;malloc_history&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;graphing-memory-over-time&quot;&gt;Graphing memory over time&lt;&#x2F;h2&gt;
&lt;p&gt;Now that we can see the Rust memory footprint, wouldn&#x27;t it be nice if we could
graph it over time?  If it&#x27;s flat we can sleep easy, and if it&#x27;s
up-and-to-the-right, well... you already know where this is going.&lt;&#x2F;p&gt;
&lt;p&gt;Murlet&#x27;s Rust backend uses an actor architecture built on the
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;slawlor&#x2F;ractor&quot;&gt;ractor framework&lt;&#x2F;a&gt;.  So I just added an actor
that periodically gathers Rust memory metrics (and some others while I was
there), and then logs them as &lt;code&gt;key=value&lt;&#x2F;code&gt; pairs that I can scrape and plot.&lt;&#x2F;p&gt;
&lt;p&gt;I ended up gathering three kinds of metrics, giving us programmatic versions of
what Activity Monitor and &lt;code&gt;vmmap&lt;&#x2F;code&gt; show:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Whole-task
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;apple-oss-distributions&#x2F;xnu&#x2F;blob&#x2F;xnu-12377.121.6&#x2F;osfmk&#x2F;mach&#x2F;task_info.h#L365-L429&quot;&gt;totals&lt;&#x2F;a&gt;,
from calling the &lt;code&gt;task_info&lt;&#x2F;code&gt; syscall with the &lt;code&gt;TASK_VM_INFO&lt;&#x2F;code&gt; flavor, via the
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;docs.rs&#x2F;mach2&#x2F;latest&#x2F;mach2&#x2F;&quot;&gt;mach2&lt;&#x2F;a&gt; crate.  These
mirror Activity Monitor&#x27;s numbers (memory footprint, resident size, graphics
memory etc.).&lt;&#x2F;li&gt;
&lt;li&gt;Per-tag region census, from traversing the process&#x27;s VM map with
&lt;code&gt;mach_vm_region_recurse&lt;&#x2F;code&gt; and bucketing each region by its
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;apple-oss-distributions&#x2F;xnu&#x2F;blob&#x2F;xnu-12377.121.6&#x2F;osfmk&#x2F;mach&#x2F;vm_statistics.h#L730-L964&quot;&gt;VM tag&lt;&#x2F;a&gt;.
These reproduce &lt;code&gt;vmmap&lt;&#x2F;code&gt;&#x27;s &quot;REGION TYPE&quot; table (IOSurface, CoreMedia,
Memory Tag 253, etc.).&lt;&#x2F;li&gt;
&lt;li&gt;Per-zone malloc
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;apple-oss-distributions&#x2F;libmalloc&#x2F;blob&#x2F;libmalloc-812.100.31&#x2F;include&#x2F;malloc&#x2F;malloc.h#L530-L535&quot;&gt;statistics&lt;&#x2F;a&gt;,
from &lt;code&gt;malloc_get_all_zones&lt;&#x2F;code&gt; and &lt;code&gt;malloc_zone_statistics&lt;&#x2F;code&gt;.  These give
&lt;code&gt;vmmap&lt;&#x2F;code&gt;&#x27;s &quot;MALLOC ZONE&quot; table, including live bytes and block counts for
&lt;code&gt;murlet_rust&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Here&#x27;s what these metrics look like when logged (census fields are region count
&#x2F; dirty megabytes):&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;14:03:22.394  INFO Actor: main::memory_logger: Memory census: regions_walked=30233 iosurface=188&#x2F;386mb ioaccel=12255&#x2F;749mb videobitstream=0&#x2F;0mb cm_xpc=0&#x2F;0mb cm_rpc=0&#x2F;0mb cm_memorypool=29&#x2F;1mb cm_readcache=0&#x2F;0mb cm_crabs=0&#x2F;0mb cm_regwarp=0&#x2F;0mb cm_hls=0&#x2F;0mb malloc=53&#x2F;215mb stack=185&#x2F;3mb tag253=5967&#x2F;216mb tag255=11273&#x2F;12mb tag0=224&#x2F;6mb elapsed_ms=63     id=&amp;quot;0.1&amp;quot; name=&amp;quot;memory_logger&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;14:06:22.327  INFO Actor: main::memory_logger: Memory totals: footprint_mb=3434 footprint_peak_mb=4205 resident_mb=1195 compressed_mb=15 internal_mb=985 graphics_mb=2745 regions=30204 rust_live_mb=16 rust_blocks=43759     id=&amp;quot;0.1&amp;quot; name=&amp;quot;memory_logger&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Don&#x27;t be alarmed by the large graphics numbers: this is the steady-state cost of
decoding nine camera streams, and these values are flat over time.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s the &lt;code&gt;murlet_rust&lt;&#x2F;code&gt; zone&#x27;s live bytes over a 22-hour run:
23MB grows to 146MB, at approximately 5 MB&#x2F;hour.  We have a leak.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;blog&#x2F;hunting-rust-memory-leaks&#x2F;murlet-rust-live-before.svg&quot; alt=&quot;Graph of murlet_rust zone live megabytes over 22 hours: a staircase climbing from 23MB to 146MB&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The staircase shape, with each step twice the size of the last, looks
&lt;em&gt;suspiciously&lt;&#x2F;em&gt; like a vector being resized...&lt;&#x2F;p&gt;
&lt;h2 id=&quot;finding-the-leak&quot;&gt;Finding the leak&lt;&#x2F;h2&gt;
&lt;p&gt;Now we need to find which code is responsible for allocating the leaking memory.
This is boringly straightforward because the Rust heap is now an ordinary malloc
zone that can be inspected by the standard macOS debugging tools.&lt;&#x2F;p&gt;
&lt;p&gt;First we start Murlet with full stack logging:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ MallocStackLogging=1 &#x2F;Applications&#x2F;Murlet.app&#x2F;Contents&#x2F;MacOS&#x2F;Murlet&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Next we let Murlet run for 30 minutes or so, and then we query its heap for any
allocation of at least a megabyte.  Fortunately, there&#x27;s just one such
allocation in the &lt;code&gt;murlet_rust&lt;&#x2F;code&gt; zone:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ heap $PID -addresses &amp;#39;.*[1m+]&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[...]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0xc39000000: non-object in zone murlet_rust_0x10e698000 (33554432 bytes)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Finally, we look at the history of this particular allocation:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ malloc_history $PID 0xc39000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[...]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VM_ALLOC 0xc39000000-0xc397fffff [size=8388608]:  0x1825aac1c (libsystem_pthread.dylib) thread_start | 0x1825afc58 (libsystem_pthread.dylib) _pthread_start | 0x10fae267c (libmurlet.darwin-arm64.node) _RNvNvMs0_NtNtNtCsaLOjE9VYtxK_3std3sys6thread4unixNtB7_6Thread3new12thread_start | 0x10faf4fe4 (libmurlet.darwin-arm64.node) core::ops::function::FnOnce::call_once$u7b$$u7b$vtable.shim$u7d$$u7d$::h98fcc8a0db9e9237 | 0x10faf3e84 (libmurlet.darwin-arm64.node) std::sys::backtrace::__rust_begin_short_backtrace::hc35dc129a6894fe9 | 0x10fb03c28 (libmurlet.darwin-arm64.node) tokio::runtime::task::raw::poll::hef21ccbb927eb4b6 | 0x10fb0ee84 (libmurlet.darwin-arm64.node) tokio::runtime::scheduler::multi_thread::worker::run::ha9faf2e2f32456d0 | 0x10fb11140 (libmurlet.darwin-arm64.node) tokio::runtime::scheduler::multi_thread::worker::Context::run_task::h400e179067fd5d00 | 0x10f7610a8 (libmurlet.darwin-arm64.node) tokio::runtime::task::raw::poll::h1f21488cb6e6bbdc | 0x10f80fd74 (libmurlet.darwin-arm64.node) _$LT$tracing..instrument..Instrumented$LT$T$GT$$u20$as$u20$core..future..future..Future$GT$::poll::h8adaa44ecb0203b9 | 0x10f7b6d78 (libmurlet.darwin-arm64.node) ractor::actor::ActorRuntime$LT$TActor$GT$::processing_loop::_$u7b$$u7b$closure$u7d$$u7d$::_$u7b$$u7b$closure$u7d$$u7d$::h2f3158dcd6b33215 | 0x10f9e52d0 (libmurlet.darwin-arm64.node) _$LT$pipeline..actor..CameraPipelineActor$u20$as$u20$ractor..actor..Actor$GT$::handle::_$u7b$$u7b$closure$u7d$$u7d$::h3417ee7abf4abdbc | 0x10f9fcca4 (libmurlet.darwin-arm64.node) pipeline::actor::CameraPipelineState::process_queue_inner::hfff498e19554538a | 0x10fab4de8 (libmurlet.darwin-arm64.node) rescaler::Rescaler::render::hb284f42963a71177 | 0x10fb877f4 (libmurlet.darwin-arm64.node) _$LT$wgpu..backend..wgpu_core..CoreDevice$u20$as$u20$wgpu..dispatch..DeviceInterface$GT$::create_texture::h0c9f3620d92dc43c | 0x10fc3a574 (libmurlet.darwin-arm64.node) wgpu_core::device::global::_$LT$impl$u20$wgpu_core..global..Global$GT$::device_create_texture::hfdef2afe9f48bd30 | 0x10fc8fbe4 (libmurlet.darwin-arm64.node) wgpu_core::registry::FutureId$LT$T$GT$::assign::hf59e8fec70d3212e | 0x10feb57dc (libmurlet.darwin-arm64.node) alloc::raw_vec::RawVecInner$LT$A$GT$::reserve::do_reserve_and_handle::hd4458fea1d3fcd66 | 0x10feb56f4 (libmurlet.darwin-arm64.node) alloc::raw_vec::RawVecInner$LT$A$GT$::finish_grow::hd58298a5d62e1af5 | 0x1823db6f8 (libsystem_malloc.dylib) _malloc_zone_realloc | 0x1823cbeac (libsystem_malloc.dylib) xzm_realloc | 0x1823cb458 (libsystem_malloc.dylib) _xzm_malloc_large_huge | 0x1823b4894 (libsystem_malloc.dylib) xzm_segment_group_alloc_chunk | 0x1823b6cb8 (libsystem_malloc.dylib) _xzm_segment_group_alloc_segment | 0x1823b3ab8 (libsystem_malloc.dylib) _xzm_range_group_alloc_anywhere_segment | 0x18256ba64 (libsystem_kernel.dylib) mach_vm_map&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Cleaning this up a bit, we have the following call stack:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;rescaler::Rescaler::render&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;└ wgpu::Device::create_texture&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  └ wgpu_core::global::Global::device_create_texture&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    └ wgpu_core::registry::FutureId::assign&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      └ alloc::raw_vec::RawVecInner::finish_grow&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        └ malloc_zone_realloc&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Hello &lt;code&gt;FutureId&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;fixing-the-leak&quot;&gt;Fixing the leak&lt;&#x2F;h2&gt;
&lt;p&gt;Some background: the &lt;code&gt;FutureId&lt;&#x2F;code&gt; in our stack is a texture id in the process of
being assigned.  Texture ids come from an
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;gfx-rs&#x2F;wgpu&#x2F;blob&#x2F;v30.0.1&#x2F;wgpu-core&#x2F;src&#x2F;identity.rs#L50-L58&quot;&gt;id allocator&lt;&#x2F;a&gt;
that contains both a free list and a &lt;code&gt;next_index&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #F8F8F2; background-color: #282A36;&quot;&gt;&lt;code data-lang=&quot;rust&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #FF79C6;&quot;&gt;pub&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #BD93F9;font-style: italic;&quot;&gt;super&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FF79C6;&quot;&gt; struct&lt;&#x2F;span&gt;&lt;span style=&quot;color: #8BE9FD;font-style: italic;&quot;&gt; IdentityValues&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    free&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FF79C6;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #8BE9FD;font-style: italic;&quot;&gt; Vec&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #8BE9FD;font-style: italic;&quot;&gt;Index&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #8BE9FD;font-style: italic;&quot;&gt; Epoch&lt;&#x2F;span&gt;&lt;span&gt;)&amp;gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    next_index&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FF79C6;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #8BE9FD;font-style: italic;&quot;&gt; Index&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6272A4;&quot;&gt;    &#x2F;&#x2F; ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If the free list is empty, &lt;code&gt;next_index&lt;&#x2F;code&gt; is used as the id (and then bumped).
Released ids are returned to the free list.  Each texture is then stored in a
vector indexed by its id.  This is the vector that keeps growing.&lt;&#x2F;p&gt;
&lt;p&gt;The bug: Texture creation is not the allocator&#x27;s only client.  wgpu v30 also
prepares an id before asking the surface for a texture.  In cases where the
acquire
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;gfx-rs&#x2F;wgpu&#x2F;blob&#x2F;v30.0.1&#x2F;wgpu-core&#x2F;src&#x2F;present.rs#L451&quot;&gt;fails&lt;&#x2F;a&gt;
or
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;gfx-rs&#x2F;wgpu&#x2F;blob&#x2F;v30.0.1&#x2F;wgpu-core&#x2F;src&#x2F;present.rs#L466&quot;&gt;returns without a texture&lt;&#x2F;a&gt;,
the prepared id is dropped without being returned to the id allocator, so it
becomes unavailable for reuse.  Murlet&#x27;s presentation is driven by a
&lt;code&gt;CADisplayLink&lt;&#x2F;code&gt; callback, which keeps firing even when the window is covered, so
hitting the error path just requires a covered window (!).  This causes us to
leak texture ids at the display rate (about 60 per second).&lt;&#x2F;p&gt;
&lt;p&gt;Interestingly, the leaked ids themselves don&#x27;t increase the size of the vector.
They drain the id allocator&#x27;s free list and then keep incrementing &lt;code&gt;next_index&lt;&#x2F;code&gt;,
so every successful texture creation is given an ever-higher index, and &lt;em&gt;these&lt;&#x2F;em&gt;
allocations grow the vector.  This is why the &lt;code&gt;malloc_history&lt;&#x2F;code&gt; stack pointed at
the (innocent!) &lt;code&gt;rescaler::Rescaler::render&lt;&#x2F;code&gt;, instead of the error path.&lt;&#x2F;p&gt;
&lt;p&gt;The fix (&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;gfx-rs&#x2F;wgpu&#x2F;pull&#x2F;10184&quot;&gt;wgpu#10184&lt;&#x2F;a&gt;) is
straightforward: a &lt;code&gt;Drop&lt;&#x2F;code&gt; implementation on &lt;code&gt;FutureId&lt;&#x2F;code&gt; that returns the id to
the free list. On successful assign we &quot;defuse&quot; the &lt;code&gt;Drop&lt;&#x2F;code&gt; with &lt;code&gt;mem::forget&lt;&#x2F;code&gt;.
It&#x27;s merged into the v30 branch, but the bug is still present in the latest
crates.io release, v30.0.1, so Murlet builds against a fork with the fix
applied.  (A refactor has already removed the bug from wgpu&#x27;s trunk.)&lt;&#x2F;p&gt;
&lt;p&gt;With the fix in place, the same series is flat at ~18MB over a 208-hour run (the
spikes are only transient; I still need to investigate why they&#x27;re happening).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;blog&#x2F;hunting-rust-memory-leaks&#x2F;murlet-rust-live-after.svg&quot; alt=&quot;Graph of murlet_rust zone live megabytes over 208 hours: flat at around 18MB with occasional short spikes&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;&#x2F;h2&gt;
&lt;p&gt;Slow, unbounded growth in data structures, especially in third-party
dependencies, is the stuff of reliability nightmares.  Rust is not a panacea
here: nothing prevents us from continually appending to a &lt;code&gt;Vec&lt;&#x2F;code&gt;. But we can see
this particular beast coming by logging memory usage and checking for a flat
line as part of our pre-release soak test. And if we build on macOS libmalloc
rather than bundling an allocator, we get powerful tooling for tracking down the
cause of any &quot;up-and-to-the-right&quot; lines. Murlet&#x27;s Rust heap has now been flat
for over a week of continuous recording.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Zero-copy wgpu rendering inside an Electron app</title>
        <published>2026-08-18T00:00:00+00:00</published>
        <updated>2026-08-18T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://murlet.com/blog/rendering-wgpu-under-electron/"/>
        <id>https://murlet.com/blog/rendering-wgpu-under-electron/</id>
        
        <content type="html" xml:base="https://murlet.com/blog/rendering-wgpu-under-electron/">&lt;p&gt;Murlet, a macOS network video recorder (NVR), displays
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;wgpu.rs&#x2F;&quot;&gt;wgpu&lt;&#x2F;a&gt;-rendered video frames straight into Electron without
them leaving GPU memory. This contributes to a great local user experience: when
a user &quot;scrubs&quot; within a recording, the displayed video is quickly rendered to
the screen, giving the sense the output is &quot;locked&quot; to the cursor position.
Nice, but how is this implemented? This blog post talks about two different
approaches: &quot;hole punching&quot; and Electron&#x27;s sharedTexture API, and explains why I
chose hole punching.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;overview&quot;&gt;Overview&lt;&#x2F;h2&gt;
&lt;p&gt;First, a brief overview of the architecture: Murlet is decomposed into an
Electron frontend and a Rust backend. The frontend is a thin React layer that is
solely responsible for rendering the UI chrome and calling into the backend. The
backend is where the business logic lives: it renders video (using wgpu),
records RTSP streams, runs object detection and owns the data model. It is
compiled into a shared library that runs within the address space of the
Electron main process, and is exposed using &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;napi.rs&quot;&gt;NAPI-RS&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;(&quot;Why Electron?&quot; you might ask. Yep, good question. Basically, I know how to
quickly ship with it and I think it&#x27;s &quot;good enough&quot; for most people. That said,
I&#x27;d love to explore a native implementation, and this would actually be pretty
easy given the current architecture. More on that later.)&lt;&#x2F;p&gt;
&lt;p&gt;Back to business. Let&#x27;s start by surveying all of our options for displaying
video frames into Electron, including those that &lt;em&gt;aren&#x27;t&lt;&#x2F;em&gt; zero-copy:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Loopback restreaming&lt;&#x2F;strong&gt;: serve local RTSP &#x2F; WebRTC streams from the backend,
decode within Electron; requires H.264 or H.265 encode&#x2F;decode round-trip.
Great for uniform local&#x2F;remote viewing story, terrible for latency.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;GPU readback&lt;&#x2F;strong&gt;: render to bitmaps on the backend, copy to the frontend,
re-render within Electron; adds some latency, burns CPU and GPU.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Hole punching&lt;&#x2F;strong&gt;: render to a native NSView peeking through a transparent
hole in Electron&#x27;s contentView. Zero-copy, platform-specific, fiddly. &lt;em&gt;This is
what Murlet uses.&lt;&#x2F;em&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Electron&#x27;s
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.electronjs.org&#x2F;docs&#x2F;latest&#x2F;api&#x2F;shared-texture&quot;&gt;sharedTexture&lt;&#x2F;a&gt;
API&lt;&#x2F;strong&gt;: this imports shared textures into Electron as VideoFrames. Zero-copy,
platform-agnostic, experimental.  Murlet does not use this, although I did
prototype it.  It provides a useful comparison to the hole punching approach.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;We&#x27;re going to skip discussion of the first two options (loopback restreaming
and GPU readback), and jump right to the zero-copy options: hole punching and
Electron&#x27;s sharedTexture API.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;hole-punching&quot;&gt;Hole punching&lt;&#x2F;h2&gt;
&lt;p&gt;The hole punching approach stacks a native NSView (the &quot;underlay&quot;) beneath
Electron&#x27;s contentView, onto which the backend directly renders video.  We then
render a transparent div within our Electron UI so that the underlay&#x27;s video can
peek through.  (Because the backend lives inside the Electron main process, it
can reach Electron&#x27;s NSWindow and add the underlay.)&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;blog&#x2F;rendering-wgpu-under-electron&#x2F;hole-punch-underlay-stack.svg&quot; alt=&quot;Exploded view of the underlay NSView stacked beneath Electron&amp;#39;s contentView, with the wgpu video aligned under the transparent div&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Aside: the hole punching technique has a &lt;em&gt;lot&lt;&#x2F;em&gt; of prior art. DirectDraw overlays
on Windows and the XVideo extension on X11 both used color keying, where the app
painted a specific color where it wanted video displayed.  Android&#x27;s
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;developer.android.com&#x2F;reference&#x2F;android&#x2F;view&#x2F;SurfaceView&quot;&gt;SurfaceView&lt;&#x2F;a&gt;
docs say that its dedicated drawing surface &quot;is behind the window holding its
SurfaceView; the SurfaceView punches a hole in its window to allow its surface
to be displayed.&quot; Browsers themselves try to render video using hardware overlay
planes, leaving a transparent hole in the main content plane for the video to
poke through.  You get the idea.&lt;&#x2F;p&gt;
&lt;p&gt;The benefit of this approach is that there&#x27;s very little coupling between
Electron and the underlay; we just have to arrange for the frontend to tell the
backend where the transparent div is located. This means that we don&#x27;t have the
performance impact of involving Electron&#x27;s rendering pipeline for every frame.
We also preserve optionality for any future native port. The flip side of the
low coupling is that we&#x27;re responsible for efficiently synchronizing both the
location and the content of the transparent div with the backend.  This is... a
little nuanced.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s start with location synchronization.  When does the location change?
Either during window resizes, or else when the user moves the separator that
sits between the video thumbnails and the video player on the search view.  If
the backend responds too slowly, then we lose the illusion that the UI is a
&quot;single pane of glass&quot;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;blog&#x2F;rendering-wgpu-under-electron&#x2F;hole-punch-underlay-mismatch.svg&quot; alt=&quot;The video has lagged behind the div, leaving an exposed strip with nothing rendered behind it&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We tackle the problem of location synchronization by &lt;em&gt;simulating&lt;&#x2F;em&gt; the frontend&#x27;s
layout algorithms (CSS grid and flexbox) on the backend, which allows us to
minimize communication. Simulation sounds complicated but it&#x27;s actually
straightforward because we have a very simple UI: the live view just has a
navbar at the top, and otherwise the video fills the rest of the window.  The
search view adds a (resizable) panel on the left containing video thumbnails,
and again the video fills the rest of the window.&lt;&#x2F;p&gt;
&lt;p&gt;So the div&#x27;s position is fully determined by a handful of edge distances. For
each edge of the transparent div, the frontend updates the backend with the
distance between the div edge and the associated window edge (e.g. for the West
edge of the div, the distance to the West edge of the window).  Call these
&quot;insets&quot;. The backend knows the window size, so it can predict the div&#x27;s
location itself.&lt;&#x2F;p&gt;
&lt;p&gt;The payoff is that the insets are invariant under window resizes due to how our
UI is designed: the frontend doesn&#x27;t have to update the backend during resizes
at all, and the positions of the div and the rendered video on the underlay stay
in almost perfect agreement.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;blog&#x2F;rendering-wgpu-under-electron&#x2F;hole-punch-insets-resize.svg&quot; alt=&quot;Two window sizes with identical inset values on all four edges&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;That said, it&#x27;s not a perfect solution: the West inset does change when the user
drags the divider on the search view, and there is some noticeable jank. My
judgement is that users rarely drag the divider, and even when they do the jank
isn&#x27;t terrible.  So a pretty good set of tradeoffs overall.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;murlet.com&#x2F;blog&#x2F;rendering-wgpu-under-electron&#x2F;hole-punch-insets-divider.svg&quot; alt=&quot;Dragging the search view divider changes the West inset&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s now talk about content synchronization.  Here the issue is that when the
user switches between the live and search views we do not want to be temporarily
displaying the previous view&#x27;s video in the new view.  Our solution is to first
render the div as an opaque black region so that the preceding view&#x27;s video is
obscured.  We then signal the backend to start rendering the new video, and then
reveal it once its pixels are &quot;on the glass&quot;.  Of course, we can&#x27;t actually know
when a frame is visible, so we use a timing fudge factor which works well.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;electron-s-sharedtexture-api&quot;&gt;Electron&#x27;s sharedTexture API&lt;&#x2F;h2&gt;
&lt;p&gt;Now let&#x27;s talk about the road not taken (but prototyped, so I guess we went down
the road a little bit): Electron&#x27;s
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.electronjs.org&#x2F;docs&#x2F;latest&#x2F;api&#x2F;shared-texture&quot;&gt;sharedTexture&lt;&#x2F;a&gt; API.
The idea here is pretty cool: a platform-agnostic way of sending textures to
Electron as
&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;developer.mozilla.org&#x2F;en-US&#x2F;docs&#x2F;Web&#x2F;API&#x2F;VideoFrame&quot;&gt;VideoFrames&lt;&#x2F;a&gt;,
without doing any copying!  And once we have video frames in the DOM, we can
build much richer user experiences than permitted by hole punching, e.g.
dragging and dropping live video, or applying CSS effects such as rounded
corners.&lt;&#x2F;p&gt;
&lt;p&gt;But... the devil is in the details. We can&#x27;t just &quot;fire-and-forget&quot; textures
from the backend to the frontend, because we still need to free them on the
backend after they&#x27;ve been composited.  So actually we maintain a pool of
textures on the backend, handing them off to Electron for display. Electron then
communicates back to the backend when it&#x27;s finished with a texture
(&lt;code&gt;allReferencesReleased&lt;&#x2F;code&gt;), whereupon we return the texture to the pool, ready
for reuse.&lt;&#x2F;p&gt;
&lt;p&gt;This works remarkably well during steady-state operation.  Where it falls apart
is resizes:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;For each resize we need to reallocate the whole pool of textures (IOSurfaces
on macOS), which is not cheap.  Meanwhile, existing in-flight textures have
all been rendered at stale sizes, so they&#x27;re stretched to fit the window&#x27;s new
aspect ratio.&lt;&#x2F;li&gt;
&lt;li&gt;Frames are serviced in bursts because the Electron renderer&#x27;s main thread is
busy laying out the page.  And &lt;code&gt;allReferencesReleased&lt;&#x2F;code&gt; callbacks are either
slow or else dropped (I didn&#x27;t root-cause this), causing our pool to run dry.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I ended up &quot;fixing&quot; this by suspending delivery of frames during window resizes.
This is not great: users want to see what their video will look like &lt;em&gt;while
they&#x27;re resizing&lt;&#x2F;em&gt;, instead of &quot;oh I overshot, let&#x27;s dial it back a bit, oops too
much, there we go&quot;. This expectation is reinforced by macOS itself (e.g.
QuickTime, which live-resizes with a locked aspect ratio). Finally, I wasn&#x27;t
keen on building on an experimental API, especially one where a single dropped
callback can cause the pool to run dry and freeze Murlet&#x27;s video.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;&#x2F;h2&gt;
&lt;p&gt;We looked at two approaches for zero-copy display of video frames into Electron:
hole punching and Electron&#x27;s sharedTexture API. Hole punching has loose
coupling, uses standard Cocoa APIs, and with a bit of work supports live
resizing. Electron&#x27;s sharedTexture API requires much more coupling, but promises
to abstract entirely over platform APIs and to make video part of the DOM. But,
at least in my prototype, it gave a very poor user experience during live
resize.  For some, this may be ok, but for me it&#x27;s a deal breaker.  So Murlet
ships with hole punching, and I&#x27;m pretty happy with the tradeoffs.  This also
leaves the door open for that native port.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
