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 VTCompressionSessionInvalidate ensures 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?

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.