Your teardown is queue.async { [weak self] }, so it might never run
I spent an evening on a Picture in Picture window that kept freezing on tvOS, ran nine experiments against it, and refuted all nine. Then I read the teardown path — which none of the nine had touched — and found a cleanup that releases two hardware video sessions and may never have been running.
It was not the freeze. That one is still open. But it was a real bug in AmbientCast, and the reason I had never noticed it is the part worth writing down: there was nothing to notice. A cleanup that silently does nothing looks exactly like a cleanup that worked.
The pattern
Reduced to the shape, with the project-specific parts taken out:
final class Compositor {
private let queue = DispatchQueue(label: "compositor")
private var encoder: VTCompressionSession?
private var decoder: VTDecompressionSession?
func stop() {
queue.async { [weak self] in
self?.invalidateSessions()
}
}
}
stop() hands the teardown to the object's own serial queue,
because the two sessions are queue-confined state and touching them from
the caller's thread would be a data race. And [weak self],
because you always write [weak self].
ELI5: what is [weak self]?
When you hand a block of work to something that will run it later, the
block normally keeps the object it belongs to alive until it runs. If the
object is also holding on to the block, neither can ever be freed — they
keep each other alive forever. Writing [weak self] tells the
block not to keep the object alive, which breaks that knot. The trade is
that the object may be gone by the time the block runs, and then the block
does nothing.
The caller, meanwhile, does something like this — and in my case it is a video source switch, which releases the whole pipeline and builds a new one:
compositor.stop()
compositor = nil // or the owner is released, and takes it along
Which is a race with a clear favourite. queue.async retains the
queue until the block runs. It does not retain self,
because you asked it not to. So the last strong reference to the compositor
can go away on the calling thread while the block is still sitting in the
queue, and when the block finally runs, self is nil,
self?.invalidateSessions() evaluates to nothing, and the
teardown is skipped.
No error. No warning. stop() returned successfully, the object
deallocated cleanly, and every instrument I had said the shutdown went fine.
Memory is the case where this is safe
If invalidateSessions() were only dropping Swift objects, none
of this would matter. ARC would free them when the compositor died, which is
exactly what just happened. [weak self] is harmless there, and
that is why the habit exists.
It stops being harmless the moment the cleanup releases something ARC does not manage. Here it was two VideoToolbox sessions.
ELI5: what is a VideoToolbox session?
VideoToolbox is Apple's low-level video encoding and decoding framework. A "session" is a handle to a configured encoder or decoder, and on Apple hardware that usually means a slice of a dedicated video chip rather than ordinary memory. The chip can only run so many at once, and it is shared with every other app on the device.
Apple's documentation for
VTCompressionSessionInvalidate
puts it precisely:
A compression session is automatically invalidated when its retain count reaches zero, but because sessions may be retained by multiple parties, it’s hard to predict when this will happen. Calling
VTCompressionSessionInvalidateensures a deterministic, orderly teardown.
The page for
VTDecompressionSessionInvalidate
says the same thing about decoders.
That is a warning, not a reassurance. The first clause says the resource does
come back eventually. The second says you do not get to know when — and
"multiple parties" includes VideoToolbox itself, which holds a session while
frames are still in flight. My teardown calls
VTCompressionSessionCompleteFrames before it invalidates,
precisely because there usually are frames in flight. Skip the block
entirely and you have handed a hardware encoder to a retain count you do not
control, at the moment you were about to ask for its replacement.
The same shape, one file over
Same app, different subsystem: the RTMP relay that pushes video to a live
stream had an identical stop(), wrapping the call that destroys
its ffmpeg context. There, skipping the block abandons a socket rather than
closing it.
The same mistake written twice in one codebase, because
[weak self] is what your fingers type. It is the right default
for a callback that may fire later. A teardown is not that.
The fix: capture self strongly, and add a deinit
func stop() {
queue.async { // strong self, deliberately
self.invalidateSessions()
}
}
deinit {
// backstop for the paths that never call stop() at all
if let decoder { VTDecompressionSessionInvalidate(decoder) }
if let encoder { VTCompressionSessionInvalidate(encoder) }
}
A strong capture keeps the object alive exactly until the teardown has run,
and releases it immediately after. There is no cycle to create: the block is
one-shot, the queue drains it, and the reference dies with it. The cycle
[weak self] defends against needs a closure the object
stores — a saved completion handler, a subscription, a repeating
timer. A queue.async you never keep a handle to is not one.
ELI5: what is deinit?
A method that runs at the instant an object is destroyed, after the last thing referring to it has let go. It is the only place you can be certain of getting one final say, which is why it suits handing back something the language will not clean up for you.
The deinit is a backstop rather than the fix. It covers the
paths where stop() is never called at all — an initialiser that
throws partway through, an owner released without its teardown hook running.
It is safe to touch queue-confined state from there: if deinit
is running then nothing else holds a reference, so there is nothing left to
race with.
Both paths log a line now, because the failure being fixed is silence:
compositor: teardown invalidated its VideoToolbox sessions (had sessions: true)
compositor: deinit found live VideoToolbox sessions -- stop() did not run
I cannot tell you how often it fired
I found this by reading the code, not by catching it happening. Before the fix there was no breadcrumb on either path, because the failure mode is silence and you do not instrument a path you believe is running. So I do not know whether it lost the race on every source switch, or once a week, or never.
I had assumed the window was tiny — the release and the queued block both happening in microseconds, with not much room between them. Reading the queue said otherwise. The teardown block goes to the back of a serial queue that admits up to four frames at a time, and each frame is a decode, a composite and an encode — about 24 ms together on average, though the decode alone can spike to nearly 45. So the teardown can sit roughly 100 ms behind the work in front of it, and past 250 ms when decode spikes, while the release that orphans it is synchronous and immediate.
Worse, and this is the part I would check first in your own code:
every other queue.async in that class is also
[weak self] — the frame path, the synthetic-source path, the
encoder's completion hop. So nothing pending on that queue retains the
object either. In a codebase where the frame blocks captured strongly, a
weak teardown would usually be rescued by accident, because something ahead
of it in the queue was holding the thing alive. Here nothing ever was. If
you find one of these, look at what else is queued alongside it before
deciding you have been getting away with it.
That uncertainty is the argument for fixing it rather than measuring it first. A race you have no way to observe is not a race you can bound.
It also explains why nine experiments on real hardware walked straight past it. Every one of them changed something about the running pipeline — the encode resolution, the number of live sessions, the player, the local server, the connections. This is damage done at teardown by code that does not execute, and a running system has nothing to measure.
What I'd tell you to check first
Grep for [weak self] inside a stop(), a
close(), a cancel(), an invalidate(),
or anything else whose job is to hand something back. For each one, ask a
single question: if this block does not run, what does not get
released?
- Swift objects and memory — fine, ARC has it, and the weak capture costs you nothing.
-
A file descriptor, a socket, a C library context, a hardware session, a
lock, a registration with another process — not fine. Capture
selfstrongly, and add adeinitfor the paths that never reachstop().
The general version, which is the one I keep having to relearn:
[weak self] is a statement that this work is
optional. That is usually true of a callback and almost never true
of a teardown. If the block has to run, say so — and if what it releases is
not memory, deinit is the only place you can say it from and be
certain.