Skip to content
Menu
← All posts
Essay11 min read

What wrapping libVLC actually cost

SwiftVLC began as a video player for an IPTV app and ended with twenty-five patches against VLC's own source. Notes on C interop, Swift 6 isolation, and the places where a wrapper stops being a wrapper.

  • Software
  • Engineering

Tilfaz is an IPTV client. IPTV means whatever the provider decided to send: MPEG-TS over UDP, HLS manifests that go stale between segments, MKV containers, SSA subtitles carrying their own styling, audio in codecs Apple has never shipped a decoder for. AVFoundation is very good at the formats Apple ships. That set and this set overlap in maybe half the cases, and the half that misses is not the half you can apologise for.

So the engine had to be libVLC — VideoLAN’s playback core, exposed as a C API, which is what VLC itself runs on. The established Apple wrapper is VLCKit: Objective-C, delegates, KVO, NSNotificationCenter, manual thread management. None of that is bad work. It is a faithful artifact of the decade it was written in. But dropping it into a SwiftUI app under strict concurrency means writing an adapter on top of an adapter, and the adapter layer is where the bugs live.

My first attempt was not a wrapper at all. In December 2025 I published harflabs/VLC, a Swift package whose entire job was making a prebuilt libVLC binary resolvable by SPM. Ship the xcframework, pin a version, move on. It is archived now, because packaging turned out to be the easy half. SwiftVLC started in February, hit 1.0 in July, and is currently 101 source files, 170 test files, and twenty-five patches against VLC’s own source — which I will get to, because they are the most interesting thing in the repository.

one mark per published tag

156 days

6 tags in 7 days1.0 · day 136MarAprMayJunJulAug

v0.1.0 — first tag, day the repo opened. The short marks are 1.1 betas.

Every published tag, placed on the real calendar: twenty of them in 156 days, with 1.0 arriving on day 136 and six of the twenty inside a single week in April. Read from the GitHub releases API on 3 August 2026.

What follows is the set of decisions I would have wanted to read about before starting.

Bind C directly, and accept the bill

The first decision was to go from C to Swift with no Objective-C in between. That buys real things. Failures become throws(VLCError), typed and exhaustive, instead of NSError codes you match on by hand. Player state becomes @Observable, so SwiftUI updates without a KVO bridge. Events become AsyncStream<PlayerEvent> with multiple independent consumers instead of notification names. Rendering becomes one view:

struct PlayerView: View {
  @State private var player = Player()

  var body: some View {
    VideoView(player)
      .onAppear { try? player.play(url: streamURL) }
  }
}

VideoView hands libVLC an NSView/UIView through set_nsobject and VLC renders into it directly. No CALayer setup, no MTKView, no AVPlayerLayer.

The bill is that you now own every C lifetime rule yourself, with no ARC-shaped layer to hide behind. Every libVLC object follows the same pattern — init allocates, deinit releases, Swift object lifetime owns C pointer lifetime — and that part is mechanical. The interesting failures all happen at the seams.

The pointer problem Swift 6 hands you

OpaquePointer and UnsafeMutableRawPointer are not Sendable under region-based isolation. That is correct of the compiler and inconvenient constantly, because releasing a C object off the main thread means capturing a pointer in a @Sendable closure. SwiftVLC allows exactly two ways to do it.

For a capture into a single closure, a local binding that opts out:

nonisolated(unsafe) let p = pointer
DispatchQueue.global(qos: .utility).async {
  libvlc_media_player_release(p)
}

The pointer is trivially transferable and stays valid for the enclosing scope, which is the whole argument. For pointers that must be read and written from several threads over time, a Mutex whose state is explicitly unchecked:

private struct State: @unchecked Sendable {
  var selfBox: UnsafeMutableRawPointer?
}
private let state = Mutex(State())

Mutex’s sending semantics want the state sendable to the callee; @unchecked honours that while the mutex does the actual excluding.

There is a third way, and it is banned in this codebase: laundering a pointer through Int(bitPattern:) and back. It compiles, it silences the diagnostic, and it destroys the two things that made the diagnostic worth having — the type and the intent. Six months later nobody reading that function can tell a live pointer from an integer, and the compiler has been talked out of helping. The rule is worth stating in the architecture document precisely because the shortcut is so easy to reach for at 1 a.m.

Events, and the deadlock at the bottom of them

Events cross three layers: C callbacks firing on libVLC’s own threads, a multi-consumer broadcaster, then @Observable properties on the main actor. The middle layer is a small generic type, Broadcaster<Element: Sendable>, shared by player events, log entries, dialog callbacks, renderer discovery, and playback intent.

How a libVLC event reaches a SwiftUI viewA C callback on a libVLC thread is mapped to a typed Swift event and handed to a multi-consumer broadcaster that runs on any thread. Each subscriber takes its own AsyncStream; the player's consumer runs on the main actor and updates the observable properties SwiftUI reads.libVLC's own threadsplayerEventCallbackmapEvent()any thread · SendableBroadcaster<PlayerEvent>subscribers snapshotted under the lock,values yielded after releasing itconsumers — one AsyncStream eachPlayer event consumer@MainActorPiPController observer@MainActoryour own for-awaitany isolation@Observable properties → SwiftUI
An event crosses two isolation boundaries before SwiftUI sees it. The second one is where the design lives: the broadcaster runs on whatever thread libVLC happened to be on, and each subscriber gets its own stream rather than a shared one.

The part worth writing down is one line of its implementation: broadcast snapshots the matching subscribers under the lock and yields outside it.

That is not a performance choice. Yielding into an AsyncStream resumes a consumer task, which acquires that task’s status-record lock. A concurrent cancellation of the same task holds that lock already and calls onTermination, which calls unsubscribe, which wants the broadcaster’s mutex. Yield while holding the mutex and you have AB-BA: two locks, two threads, opposite order. It is the kind of deadlock that never appears under test and appears immediately in the hands of someone scrubbing a live stream on a train.

The AB-BA cycle between the broadcaster mutex and a task's status-record lockBroadcast holds the broadcaster mutex and needs the task status-record lock in order to resume a consumer. A concurrent cancellation holds the status-record lock and needs the broadcaster mutex in order to unsubscribe. Each waits on the lock the other holds.broadcast(event)libVLC's threadtask cancellationany threadBroadcastermutextask status-record lockholdsholdsyield resumes the consumer → needsonTermination → unsubscribe → needs
Two locks, two threads, opposite acquisition order. Neither side is doing anything unusual — one is delivering an event, the other is cancelling a task — which is exactly why the cycle is easy to write and hard to see.

The same type carries a distinction that looks like over-engineering until it doesn’t. finishAll() closes the current subscribers and allows resubscribe. terminate() closes them and makes every future subscribe call return an immediately-finished stream. Both exist because some broadcasters are reached through a computed property — handler.dialogs builds a fresh subscription on each access. When the producer behind it is permanently gone, a subscriber that arrives late must get a finished stream rather than a live one nobody will ever feed. Without terminate(), the failure mode is not a crash. It is an await that never returns, which is much worse to diagnose.

Deinit order is part of the API

Player.deinit does four things in one order that is not negotiable:

  1. cancel the event-consumer task
  2. EventBridge.invalidate() — detach the C listeners, finish the continuations, release the retained store
  3. libvlc_media_player_stop_async()
  4. libvlc_media_player_release()

Detaching the listeners before releasing the player is what prevents a callback firing into freed memory during teardown. Swap steps 2 and 4 and everything still compiles, the tests still pass, and the crash reports arrive weeks later from users whose network dropped at the wrong moment. This is the least glamorous kind of design decision and among the most load-bearing, which is why it is written down rather than left for a reader to infer.

Where the wrapper stops being a wrapper

Picture-in-Picture is where the abstraction stopped being mine.

The governing fact is that libVLC copies your video-memory callback pointers and their opaque context when a video output opens. Clearing the callback variables on the media player afterwards does not revoke a copy already held by that output. The obvious teardown — unset the callbacks, then free the context — is therefore a use-after-free waiting for a vout that has not finished with you yet.

The fix ties every retained opaque to one exact libvlc_media_player_t, so sequential controllers on the same native handle can hand over atomically while an overlapping output can never touch another output’s dimensions, pool, or cleanup state. Retirement suppresses new display work immediately, but the opaque is released only after the final counted native release for that handle returns and every callback already in flight has drained. No timeout, and no transient vout observation, is treated as proof of safety. That sentence is the whole policy: with C callbacks, “it has probably finished by now” is not a lifetime.

PiP also forced the one decision I am least comfortable with and most confident about. On iOS, PiPVideoView uses libVLC’s own native drawable path and the system PiP controller it owns; a directly constructed PiPController instead installs public vmem callbacks and drives an AVSampleBufferDisplayLayer. On macOS, the public sample-buffer mirror crops at 1:1 layer size instead of scaling into the PiP panel — the video is simply wrong on screen. The working path is a private framework: load PIPViewController out of PIP.framework at runtime and reparent VLC’s real drawable view into it.

  • VideoViewall platforms

    set_nsobjectNSView / UIView

    VLC draws into your view

  • PiPVideoViewiOS

    drawable proxyVLC's iOS sample-buffer voutAVPictureInPictureController

    controller owned by libVLC

  • PiPControllerdirect, public API

    vmem callbacksCVPixelBufferCMSampleBufferAVSampleBufferDisplayLayer

    layer owned by SwiftVLC · 8-bit BGRA, SDR

  • PiPVideoViewmacOS, SPI opt-in

    set_nsobjectVLC's own NSViewPIPViewController

    private PIP.framework · off by default

Four rendering paths. Read them for ownership rather than for boxes: on iOS the PiP controller belongs to libVLC and is handed back, in the direct path the layer belongs to SwiftVLC, and the only macOS path that renders correctly belongs to a private framework.

So it ships, disabled by default, behind an explicit allowsPrivateMacOSAPI opt-in, and every private-symbol reference in the library lives in exactly one file. Not because that makes it App Store safe — it does not, and that is the point of the opt-in — but because an auditor asking “what private API does this library touch?” should be able to read one file and be done, rather than trust a grep across a hundred.

The same honesty principle shows up in a smaller place: the iOS native PiP backend deliberately reports PiP unavailable in the Simulator. Simulator AVKit will happily report an active sample-buffer PiP controller while the system window stays black. A test that passes against that is not a passing test, it is a lie with a green checkmark, so end-to-end PiP has to be exercised on a physical device.

Twenty-five patches

The thing nobody tells you about building on a large C engine: at some point you stop reading its headers and start reading its source, and shortly after that you start changing it.

scripts/patches/ holds twenty-five ordered patches applied against a pinned VLC revision at build time, with a checksum manifest.

25
patches
4,606
lines changed
56
VLC files touched
  • Apple video output & PiP8
  • libVLC C API8
  • Input & player core6
  • Demuxers — MP4, TS, HLS4
  • Build & test scaffolding3
  • Chromecast & stream out2
  • UPnP discovery2
  • avcodec1

patches touching this area

Provenance, from each patch's own header

  • 9reproduces an upstream commit unchanged
  • 16written for SwiftVLC
Where the twenty-five patches land, counted by parsing each diff rather than by reading its title — a patch is counted once per area it modifies, so the bars sum to more than twenty-five. Provenance comes from each patch's own header: nine reproduce an upstream commit unchanged, and the remaining sixteen were written here. Together they change 4,606 lines across 56 files of VLC.

Some are backports of upstream fixes that landed after the pin. Patch 12 is the player-timer series: on the pinned revision the timer interpolates past the pause point, so a paused player keeps reporting a time that advances. That surfaces directly as Player.currentTime, and PiP’s control timebase is placed from the same reading, so the visible symptom is a paused video with a drifting scrubber. The patch carries two upstream refactors it depends on, verbatim, rather than hand-adapting the two real fixes onto the older shape — a rebase you can re-derive beats a rewrite you have to re-verify.

Others are original. Patch 8 fixes a use-after-free when an encrypted segment’s chunk cannot be prepared: ISegment::toChunk() releases the chunk source twice on the prepareChunk() failure path, once explicitly through recycleSource(), and again when it deletes the chunk, because ~AbstractChunk() releases the source the chunk owns. For segment chunks the recycle is not cacheable and goes straight to delete, so the second release dispatches through a vtable on freed memory. It reproduces under AddressSanitizer, and the crash site matches an existing VLC report of intermittent EXC_BAD_ACCESS on iOS arm64 after roughly ten minutes of HLS playback.

Read the conditions on that one: encrypted segments only, and only when the AES-128 key fails to resolve. Flaky network, real device, ten minutes in. That bug does not exist in your test suite. It exists in your reviews.

Ship the thing your users actually resolve

Two infrastructure decisions earned their keep.

The published state of the repository — main and every release tag — carries the remote form of the binary dependency, url: plus checksum:, so the default state of the repo is the state a downstream consumer resolves. Local development flips that to an on-disk path with a script, and CI rewrites it back to the latest released xcframework before running tests. The tests therefore run against the same binary a stranger gets from SPM, not the one sitting in my Vendor/ directory.

And the tests use no mocks at all. Every test creates a real Player and real Media against the real libVLC binary and about 50 KB of fixture media. Mocking a media engine tests your understanding of the media engine, which is precisely the thing under suspicion. The cost is that a media test does not fail when it goes wrong — it hangs. So CI wraps the run in a watchdog with a ten-minute wall clock and a three-minute idle timer that SIGKILLs the process group when either fires. A separate workflow builds layered dynamic-host fixtures and asserts that an app ends up loading exactly one copy of libVLC, which is the sort of thing you only think to check after it has gone wrong once.

Was it worth it

Five months from first commit to 1.0, for a library whose interesting part is maybe fifteen of its hundred-odd files. All fifteen sit on a boundary between ownership models that disagree: C’s manual lifetimes, Swift 6’s compile-time isolation, and AVKit’s undocumented expectations about who retains whom. The rest is typing.

If you are considering wrapping a large C library, the honest advice is to budget for its source rather than its headers. The API surface is a weekend of work per module and mostly writes itself. What costs months is the part where the engine’s assumptions and your language’s guarantees are both correct and incompatible, and only one of them can be changed by editing your own repository.

Latest posts

Topics

See all results