Engineering
One missing autoreleasepool made our video export grow past 70 GB
Shotnix is a Mac screen recorder with a video editor built in. A user reported that exporting a 3-minute recording made macOS close the app. The recording was a Retina area, 3010 × 1716 pixels, and by the time macOS stepped in, Shotnix had grown past 70 GB.
The cause was one loop with no autorelease pool in it.
The export loop
The exporter reads the recording with AVAssetReader and writes the new video with AVAssetWriter. For each output frame it decodes a source frame, renders it with Core Image (zoom, cursor, background, captions) and appends the result through a pixel buffer adaptor.
The writer pulls frames through requestMediaDataWhenReady(on:using:). Before the fix, the callback looked like this, shortened:
videoInput.requestMediaDataWhenReady(on: videoQueue) { [self] in
while videoInput.isReadyForMoreMediaData {
if cancel.isSet {
finishVideo(group)
return
}
if !videoDone {
if !appendNextFrame() {
videoDone = true
// ...the end card, then finishVideo(group)
}
}
// ...
}
}That's the usual shape for this callback: keep appending while the input is ready, return when it isn't.
Where the memory went
The reader hands out decoded frames as BGRA pixel buffers. At 3010 × 1716 that's 3010 × 1716 × 4 bytes, about 20.7 MB per frame.
ARC releases what Swift owns as soon as it goes out of scope. Autoreleased objects are different: they live until the autorelease pool around them drains. Somewhere in the decode and render path, Objective-C frameworks hand back autoreleased objects that still hold on to the decoded frame.
At best, a pool drains when the callback returns. During an export the callback barely returns. The writer is almost always ready for more, so the while loop keeps going, and every frame decoded inside it stays alive until the writer pauses.
A 3-minute recording is thousands of frames. At 20 MB each, the app ran past 70 GB and macOS closed it, sometimes in the middle of a new recording.
The fix: one pool per frame
I moved the loop body into a method, writeNextVideoFrame(), which returns false once the video is finished, failed or cancelled. Then each frame gets its own pool:
videoInput.requestMediaDataWhenReady(on: videoQueue) { [self] in
// A pool per frame: the writer can keep asking for frames
// for the whole export.
while videoInput.isReadyForMoreMediaData {
guard autoreleasepool(invoking: writeNextVideoFrame) else {
finishVideo(group)
return
}
}
}The audio callback got the same thing, a pool per sample buffer. So did the GIF exporter's frame loop.
Then there were the loops that read a whole track in one go: the loudness meter, the waveform, and the audio reader for captions. They all had the same while let sample = output.copyNextSampleBuffer() shape, so they now go through one small helper:
extension AVAssetReaderOutput {
/// Hands each sample buffer to `body` in its own autorelease pool, until
/// the output runs out or `body` returns false. Without the pools a loop
/// over a whole track keeps every buffer it read until the loop ends.
func forEachSampleBuffer(_ body: (CMSampleBuffer) throws -> Bool) rethrows {
while try autoreleasepool(invoking: { () throws -> Bool in
guard let sample = copyNextSampleBuffer() else { return false }
return try body(sample)
}) {}
}
}The helper makes the pool the default instead of something to remember.
Measuring it
An export now uses about the same memory from its first frame to its last. A 5-minute 3010 × 1716 recording exports in 4K in under 1.5 GB.
To keep it that way there's an opt-in test, EditorMemoryValidationTests. It records 5 minutes of a Retina area through the real recording engine, then plays, scrubs, edits and exports it in the real editor, logging the app's footprint as it goes. The footprint is phys_footprint from task_info:
static var footprint: UInt64 {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)
let result = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
return result == KERN_SUCCESS ? info.phys_footprint : 0
}It's opt-in because recording 5 minutes takes 5 minutes.
The filmstrip, too
The same commit fixed the editor's timeline filmstrip. It called copyCGImage(at:actualTime:) once per picture, and each call opened its own decoder, 160 of them for a long recording. Now it asks for every picture in one pass with images(for:):
static func generate(with generator: AVAssetImageGenerator, at times: [Double], offset: Double = 0) async -> [VideoTimelineThumbnail] {
let requests = times.map { CMTime(seconds: $0, preferredTimescale: 600) }
var thumbnails: [VideoTimelineThumbnail] = []
for await result in generator.images(for: requests) {
guard let image = try? result.image else { continue }
// ...match result.requestedTime back to the time that was asked for
thumbnails.append(VideoTimelineThumbnail(time: offset + time, image: NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))))
}
return thumbnails.sorted { $0.time < $1.time }
}One decoder for all the pictures. For a 5-minute recording the timeline fills in about a quarter faster.
What I'd keep in mind
- A loop that decodes or renders frames needs an
autoreleasepoolper iteration. A GCD callback or an async function won't drain one for you on every pass. - Test with long inputs. This leak grows with every frame, so the length of the recording is what exposes it.
- Watch
phys_footprintwhile it runs, not just whether the export finishes.
Shotnix is a free, open-source screen recorder for Mac. The whole fix is commit 0aeb371.
Try the app this came from.
Shotnix is a free, open-source Mac app for screenshots, screen recordings, and video editing. No account, no watermark.
11.7 MB · Apple silicon (M1 or later) · macOS 13+ · notarized by Apple · MIT license