Wave Link 3.2.2 increases its memory footprint continuously. We observed no bound. The process ran for 5.9 days. Its physical footprint reached 3005 MiB. 2724 MiB, or 90.7%, of that footprint holds autorelease pool pages. Those pages hold pointers and pool boundary markers. They are not object allocations.
The process owns 697,437 AutoreleasePoolPage allocations. Each page is 4096
bytes. These pages are 97.1% of all malloc bytes in the process. The page count
only increased during our observation. We never saw it decrease.
Our leading hypothesis is an ObjC autorelease that occurs with no outer pool in scope.
It occurs on a worker thread that Wave Link creates. A plain pthread has no outer
pool that a system component drains automatically. libobjc therefore appends each
pointer to a chain of pages, and that chain appears never to drain.
This report gives the measurements. It names a leading suspect. It proposes a fix and a verification method. We keep the attribution probabilistic. The last section states the limits of our evidence.
| Item | Value |
|---|---|
| Application | Elgato Wave Link 3 |
| Version | 3.2.2 |
| Build | 2896 |
| Bundle ID | com.elgato.WaveLink3 |
| OS | macOS 26.2, build 25C56 |
| Architecture | arm64, Apple Silicon |
| Process uptime at measurement | 5 days 22 h 01 min |
We used footprint, vmmap, heap, and sample. All four tools are read-only.
ps reports the resident set size. The RSS of this process stayed between 54 MB
and 88 MB. That value looks correct.
The true value is phys_footprint. It was 3005 MiB. The peak was 3090 MiB.
vmmap explains the difference:
Writable regions: Total=3.0G written=2.8G(94%) resident=31.1M(1%) swapped_out=2.8G(93%)
The numbers show that 93% of the writable memory was cold. macOS compresses and swaps out pages that a process does not touch for a long period. Therefore the RSS stays small. The process still owns almost 3 GiB of dirty memory.
An engineer who checks only ps RSS finds no problem. Use footprint -p <pid> for
the measurements in this report. We did not measure the memory columns of Activity
Monitor. Therefore we make no claim about what those columns show.
Process <pid>: 9 zones
All zones: 1212056 nodes (2942112110 bytes)
COUNT BYTES AVG CLASS_NAME TYPE BINARY
===== ===== === ========== ==== ======
697437 2856701952 4096.0 @autoreleasepool content C libobjc.A.dylib
146179 43156705 295.2 non-object
104228 5879168 56.4 CFString ObjC CoreFoundation
14191 1570144 110.6 Closure context Swift <unknown>
Only the first row is abnormal. The other rows are normal for an application of this type.
An AutoreleasePoolPage on arm64 macOS is 4096 bytes. It holds approximately 505
slots after its header. Therefore 697,437 pages supply approximately 350 million
pool slots.
We say slots, not objects. A slot holds either an object pointer or a pool boundary marker. The final page in the chain may also be partly used. Therefore 350 million is the capacity of the chain. It is an upper bound on the pending entries, not an exact count.
The malloc node count stays low at 1.2 million, while the chain supplies about 350
million slots. heap does not report the contents of a slot. Therefore we cannot say
what fills them. Several explanations are possible:
- Repeated pointers to a small set of long-lived objects.
- Pool boundary markers and other runtime sentinels.
- Slots in pages that the chain retains but does not currently use.
We do not choose between these. The measurement that matters does not depend on the choice: the page count itself grows without limit. Only instrumentation inside the application can identify the slot contents.
We make no claim about the resulting retain counts. objc_autorelease does not add a
retain. It only schedules a later release. Some observed call sites use
objc_retainAutoreleaseReturnValue, which does retain. Other call sites use
objc_autoreleaseReturnValue, which does not retain. An exact ownership count needs
instrumentation inside the application. We cannot do that from outside a release
build.
We made four measurements across 25.3 minutes. The times are relative.
| Time | Pool pages | Pool bytes | phys_footprint |
|---|---|---|---|
| t+0 s | 694,612 | 2713.3 MiB | 2994 MiB |
| t+761 s | 694,919 | 2714.5 MiB | — |
| t+981 s | 696,439 | 2720.5 MiB | 3001 MiB |
| t+1518 s | 697,437 | 2724.4 MiB | 3005 MiB |
Results:
- Directly measured. The window shows +2,825 pages and +11.04 MiB. The rate is about 26 MiB/hour. This is the defensible rate figure in this report.
- Estimate. The lifetime average is about 460 MiB/day, and the implied rate is about 689 slots per second. Both divide the final pool size by the process uptime. We did not measure a baseline at launch. Therefore both assume the pool started near zero. Treat them as order-of-magnitude estimates only.
The short-term rate changes with audio activity. One window of 761 s grew at 6 MiB/hour. A later window of 220 s grew at 102 MiB/hour. The direction never reversed during our observation. We saw no release of these pages.
This growth is the strongest part of our evidence. It is a direct, process-wide measurement. It does not depend on any sampled stack.
We ran sample against the live process. The following frames occur on the worker
thread. They show libobjc as it allocates a new pool page:
-[AUParameter setValue:extOriginator:atHostTime:eventType:] + 216
objc_autoreleasePoolPush + 240
AutoreleasePoolPage::autoreleaseFullPage(objc_object*, AutoreleasePoolPage*) + 68
malloc_type_posix_memalign + 164
libobjc calls autoreleaseFullPage only under one condition:
- The current hot page is full, and libobjc must link a new page into the chain.
The malloc_type_posix_memalign frame below it is the allocation of the 4096 bytes.
The same thread also shows the append path:
objc_autorelease + 160
objc_object::rootAutorelease2() + 96
AutoreleasePoolPage::add(objc_object*) + 180
rootAutorelease2() is the slow path. libobjc reaches it when the inline fast path
cannot place the pointer.
Wave Link creates this thread. These are its base frames:
thread_start (libsystem_pthread.dylib)
_pthread_start (libsystem_pthread.dylib)
WaveLinkMacOS + 0x5c6c2c
WaveLinkMacOS + 0x5c45b0
invocation function for block in AVAudioEngineImpl::GetManualRenderingBlock()
AVAudioEngineGraph::RenderToABL(unsigned int, AudioBufferList*, int*)
The thread is a plain pthread. It drives manual rendering for AVAudioEngine in a
loop. A second branch on the same thread paces the loop with mach_wait_until.
This detail is important. A plain pthread has no outer autorelease pool that any
system component drains automatically and predictably. The thread owner must supply
that pool.
Other thread types do get a drained pool in practice:
- The AppKit main thread. Its run loop pushes a pool and pops it around each callout.
On the main thread of this process,
sampleshows__CFRunLoopPerCalloutARPEndas it callsobjc_autoreleasePoolPop. Note that a bareCFRunLoopon a secondary thread does not guarantee this behaviour by itself. - A libdispatch worker that runs an async block. libdispatch wraps the block in a
pool.
dispatch_syncdoes not do this, because it borrows the calling thread.
This worker thread matches neither pattern. Therefore Wave Link must create and drain any outer pool on it.
flowchart TD
subgraph OK["Threads with a pool that is drained for you"]
A1["Main thread<br/>CFRunLoop"] --> A2["push pool per callout"]
A2 --> A3["pop pool per callout<br/>__CFRunLoopPerCalloutARPEnd"]
A3 --> A4["Page chain stays short"]
B1["libdispatch worker<br/>async block"] --> B2["push and pop per block"]
B2 --> B4["Page chain stays short"]
end
subgraph BAD["This worker thread"]
C1["pthread created by Wave Link<br/>manual rendering loop"] --> C2["No implicit pool"]
C2 --> C3["autorelease with no pool in scope"]
C3 --> C4["AutoreleasePoolPage chain grows<br/>forever"]
end
style BAD fill:#3a1f1f,stroke:#c0392b,color:#fff
style OK fill:#1f3a24,stroke:#27ae60,color:#fff
sample shows this call chain on the same thread. The frame counts show presence.
They do not show cost.
WaveLinkMacOS + 0x5c6c48
WaveLinkMacOS + 0x2c5780
WaveLinkMacOS + 0x5c61e0
WaveLinkMacOS + 0x583d10
WaveLinkMacOS + 0x581b3c
-[AVAudioUnitVarispeed setRate:]
-[AVAudioUnit setValue:forParam:]
AUInterfaceBaseV3::SetParameter(unsigned, unsigned, unsigned, float)
-[AUAudioUnitV2Bridge parameterTree] <- autoreleased return
-[AUParameterTree parameterWithID:scope:element:]
-[AUParameterTree parameterWithAddress:] <- autoreleased return
-[AUParameter setValue:]
AUInterfaceBaseV3::SetParameter autoreleases two returned objects:
-[AUAudioUnitV2Bridge parameterTree], throughobjc_retainAutoreleaseReturnValue-[AUParameterTree parameterWithAddress:], throughobjc_autoreleaseReturnValueand thenobjc_autorelease
These two calls are our candidates for the accumulation.
Please read the next limitation carefully. sample also shows
objc_autoreleasePoolPush and objc_autoreleasePoolPop frames inside
-[AUParameter setValue:extOriginator:atHostTime:eventType:]. A statistical profile
cannot pair a push with its pop. It also cannot show which lexical pool is active at
the moment of an autorelease. Therefore we cannot prove the scope of each entry from
these stacks. We can only state the candidates, and combine them with the monotonic
page growth from section 2.
sequenceDiagram
participant W as Wave Link worker loop
participant AV as AVFAudio
participant AT as AudioToolboxCore
participant OBJC as libobjc pool pages
Note over W,OBJC: One render pass on a thread with no known outer pool
W->>AV: -[AVAudioUnitVarispeed setRate:]
AV->>AV: -[AVAudioUnit setValue:forParam:]
AV->>AT: AUInterfaceBaseV3::SetParameter
AT-->>OBJC: parameterTree, autoreleased. CANDIDATE entry
AT-->>OBJC: parameterWithAddress:, autoreleased. CANDIDATE entry
AT->>AT: -[AUParameter setValue:]
AT->>OBJC: objc_autoreleasePoolPush seen (pairing not provable)
AT-->>OBJC: inner autoreleases
AT->>OBJC: objc_autoreleasePoolPop seen (pairing not provable)
Note over OBJC: Candidate entries may accumulate in a missing
Note over OBJC: or overly long-lived outer pool
Note over OBJC: A page supplies ~505 slots, then autoreleaseFullPage
Here is a consistency check on the hypothesis. Assume two entries accumulate per
render pass. Assume a 512-frame buffer at 48 kHz. That gives about 188 new slots per
second. Our estimated lifetime rate is about 689 slots per second. The magnitude
agrees. The difference suggests other call sites on the same thread. Therefore
setRate: is probably not the only source.
A second observation points the same way. sample also caught
-[AVAudioConverter sampleRateConverterAlgorithm] as it reached
objc_autoreleaseReturnValue. It also caught
-[AVAudioPCMBuffer initWithPCMFormat:bufferListNoCopy:deallocator:]. Both occurred
on the CoreAudio HAL IO threads, named com.apple.audio.IOThread.client. Those
threads also have no implicit pool. See item 3 under Suggested fix.
We recommend that you ship this change. The render path performs work that is not real-time safe. The leak is a symptom of that work.
sample shows that -[AUParameterTree parameterWithAddress:] spends most of its
frames in objc_loadWeakRetained and weak_entry_for_referent. That code performs a
lookup in the weak reference table, and it takes a global lock.
AUInterfaceBaseV3::SetParameter then autoreleases the objects that it returns.
Neither operation belongs on an audio render thread. This is true with a pool and
without a pool.
1a. Resolve the parameter one time, during setup. Hold a strong reference.
// Setup, once, off the render thread.
let rateParam = varispeed.auAudioUnit.parameterTree?
.parameter(withAddress: kVarispeedParam_PlaybackRate)1b. Drive the value with a cached scheduling block that is real-time safe.
You may adjust the rate on each render pass to correct clock drift. In that case the
AUParameter setter is the wrong tool. AUAudioUnit supplies
scheduleParameterBlock. Apple documents the guarantee in AUAudioUnit.h:
As with renderBlock, a host should fetch and cache this block before calling
allocateRenderResources, if it intends to schedule parameters.The block is safe to call from any thread context, including realtime audio render threads.
We rely on that documented contract. We make no claim about the internals of the block. Cache the block one time, then call it from the render context.
// Setup, once, off the render thread.
let schedule = varispeed.auAudioUnit.scheduleParameterBlock
let rateAddress = rateParam!.address
// Render thread: Apple documents this block as safe here.
schedule(AUEventSampleTimeImmediate, 0, rateAddress, newRate)The ObjC calls then leave the loop body. The autorelease traffic from this call site should stop. Verify the result with the method in the next section.
flowchart LR
subgraph NOW["Current"]
N1["render pass"] --> N2["setRate:"]
N2 --> N3["parameterTree<br/>autoreleased"]
N3 --> N4["parameterWithAddress:<br/>autoreleased + weak lock"]
N4 --> N5["setValue:"]
N5 --> N6["2 candidate entries per pass<br/>may accumulate"]
end
subgraph FIX["Proposed production fix"]
F1["setup once:<br/>cache AUParameter address<br/>+ scheduleParameterBlock"] --> F2["render pass"]
F2 --> F4["schedule(...)<br/>documented safe on realtime threads"]
F4 --> F5["No autorelease traffic<br/>page chain stable"]
end
style NOW fill:#3a1f1f,stroke:#c0392b,color:#fff
style FIX fill:#1f3a24,stroke:#27ae60,color:#fff
This change is not real-time safe. We do not propose it as the shipping fix. A pool pop sends
releaseto every pending object. That action can cause adealloc, afree, and a lock at an unpredictable point in the render pass. The audio can then drop out. Use this change to bound the memory and to confirm the diagnosis. Do not use it as a substitute for item 1.
while running {
autoreleasepool {
renderOnePass()
}
waitForNextDeadline()
}You may not be able to ship item 1 quickly. In that case a variant carries less risk:
- Drain the pool on a coarse schedule, for example one time in every N render passes.
- Or move the ObjC work to a separate thread that is not real-time.
Both variants accept a bounded memory ceiling. Both accept some jitter from the pool pop. They let you choose the place of that jitter.
sample caught autorelease traffic on the CoreAudio HAL IO threads. The calls were
-[AVAudioConverter sampleRateConverterAlgorithm] and
-[AVAudioPCMBuffer initWithPCMFormat:bufferListNoCopy:deallocator:]. These threads
also have no implicit pool. Apply the same rule. Keep the creation of ObjC objects out
of the IO callback.
Do not rely on ps RSS. It hides this bug. Use the commands below.
Follow these steps:
- Start Wave Link. Note the pid.
- Record a baseline:
heap <pid> | grep '@autoreleasepool' footprint -p <pid> | grep phys_footprint
- Keep the application active with audio for one hour or more.
- Repeat step 2.
A correct fix gives this result:
- The
@autoreleasepool contentpage count becomes stable. - The count varies around a small value, in the low hundreds of pages at most.
- The count does not increase in one direction.
For a faster signal, run the application with MallocStackLogging. Then examine the
backtraces of the pages that survive. The shipping build does not enable it, and
heap reports this:
Type names for non-objects could be derived from allocation backtraces
if the process used MallocStackLogging
Our evidence is read-only, and it comes from a release build. Therefore some links are inferences. We state them plainly.
- Proven. The process holds 697,437
@autoreleasepool contentpages. They total 2724 MiB. The count increased across four measurements. - Proven. libobjc allocated a new pool page on the Wave Link worker thread. The
frames were
AutoreleasePoolPage::autoreleaseFullPageandmalloc_type_posix_memalign, below theAUParametersetter. - Proven. Wave Link creates that worker thread with
pthread. The thread is not aCFRunLoopthread. It is not a libdispatch async worker. - Not proven. That one thread owns all 697,437 pages. Pool pages are
thread-local. We saw page allocation frames only on this thread. Therefore this is
the most probable explanation. Direct proof needs the owning-thread field in each
page header. That memory is unreadable. The hardened runtime restricts the process:
For the same reason,Process <pid> is not debuggable. Due to security restrictions, leaks can only show or save contents of readonly memory of restricted processes.leakscannot run, andlldbcannot attach. - Not proven. That the two
SetParameterreturn values escape a pool. A statistical profile cannot pair aobjc_autoreleasePoolPushframe with itsobjc_autoreleasePoolPopframe. It also cannot report the active lexical pool at the moment of an autorelease. We therefore present those two calls as candidates. - Not proven. The exact count of autoreleases per render pass. Also the exact
share from
setRate:against other call sites. A sample shows presence and rough proportion. It does not show call counts. Our estimated 689 slots per second is more thansetRate:alone explains, but see the caveat on that estimate above. - Not measured. The effect of specific product features on the rate. The short-term rate varied by a factor of 17 during our observation. Therefore the rate depends on activity that we did not control.
You can settle points 4, 5, and 6 quickly with an internal build. Enable
MallocStackLogging. Or add a counter around objc_autorelease on that thread.
Treat this section as context. It is not a severity claim.
At the time of measurement the host used about 19 GiB of swap. The swap pool grew from 16 GiB to 20 GiB during an observation of 30 minutes. macOS grows the swap pool on demand while free disk space allows. Therefore this is not a fixed ceiling, and it is not a failure by itself.
The kernel reported kern.memorystatus_vm_pressure_level: 1. That is the normal
level. Free memory was 48% of the system. The host was not under memory pressure.
The real cost is different. Each Wave Link process holds about 2.7 GiB of dirty, swapped-out memory. That amount grew throughout our observation. Only a quit of the application returns it. Users leave a menu-bar audio utility running for weeks. The cost therefore accumulates against every other application on the machine.
We needed no special steps.
- Run Wave Link 3.2.2 on macOS 26.2, arm64, with an active audio device.
- Leave the application running.
- Sample the pool page count over time:
heap <pid> | grep '@autoreleasepool'
The count increased throughout our observation. It never decreased.