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 favorite. 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 initializer 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.

Update: the freeze was the audio session

I said the freeze was still open. It isn't any more, and it was not this. It was an audio problem.

The app held an interrupting .playback session and played an unmuted silent AAC track — the muxer puts one in every segment, and the decoy player runs at volume = 0 rather than muted because AVKit's Picture in Picture eligibility check trips on isMuted but not volume. Both exist purely to keep PiP working. Adding .mixWithOthers to the session category fixed it: thirty-six minutes clean after one source switch and four after a second, zero freezes, against a previous distribution of a freeze within 45–100 seconds of every switch without exception.

Which makes the point of this post sharper, not weaker. Every one of those nine experiments replaced something on the video side — the player item, the player, the layer, the local server, the encoder, the bitrate, the number of decode sessions, the order of teardown. Not one touched audio. I had thirteen hypotheses by the end and they were all about video, because the symptom was a frozen video window. The visible subsystem recruits every hypothesis you form.

The tell was there the whole time and I read it backwards: another app froze too, and killing mine released it. A symptom that crosses into another process is telling you what is shared — and audio was the one system-wide claim my app held. I took it as evidence of a platform bug instead, twice, and got as far as drafting a Feedback report.

The teardown bug stands on its own, which is why it was worth writing up separately. One honest correction: I later added a live instance counter, and in every teardown I have since watched, the cleanup won its race — the sessions were already released by the time deinit ran. So it may never have leaked in production. That is exactly the case I left room for above ("on every source switch, or once a week, or never"), and I would still make the same fix: a cleanup that can silently do nothing is worth closing whether or not you can prove it fired.